Advanced navigation
This page covers advanced navigation patterns for the KMP / Decompose path. For the basics, see Navigation (KMP).
Consolidated navigation
For nav-hosts with many screens, we recommend consolidating all navigation logic into a dedicated class. This keeps the nav-host component decluttered and navigation logic easy to find.
The pattern works as follows:
- Each screen component defines its navigation as an interface with extension functions on itself (e.g.
fun HomeComponent.navigateToDetail()). This scopes each navigation call to the component that triggers it and allows duplicate function names likenavigateBack()across different screens. - The nav-host defines an internal interface that extends all child screen navigation interfaces and holds the
stackNavigator. - A single implementation class provides all navigation logic.
// Each screen defines its own navigation interface
interface HomeScreenNavigation : NavigationActions {
fun HomeComponent.navigateToDetail()
}
interface DetailScreenNavigation : NavigationActions {
fun DetailComponent.navigateBack()
}
// The nav-host's internal interface consolidates them all
internal interface HomeNavHostNavigation :
HomeScreenNavigation,
DetailScreenNavigation {
val stackNavigator: StackNavigation<HomeDestination>
}
// Single implementation handles all navigation
internal class HomeNavHostNavigationImpl : HomeNavHostNavigation {
override val stackNavigator = StackNavigation<HomeDestination>()
override fun HomeComponent.navigateToDetail() =
stackNavigator.pushNew(HomeDestination.Detail)
override fun DetailComponent.navigateBack() =
stackNavigator.pop()
}
The nav-host receives the navigation instance and passes it to child factories. Because the consolidated interface extends every screen's navigation interface, the same instance satisfies all children:
@GenerateFactory
internal class HomeNavHostComponent(
@InjectedParam componentContext: AppComponentContext,
@InjectedParam private val navigation: HomeNavHostNavigation,
) : AppComponent<Unit, Nothing>(componentContext, Unit) {
val stack = childStack(
source = navigation.stackNavigator,
serializer = HomeDestination.serializer(),
initialConfiguration = HomeDestination.Home,
childFactory = { destination, ctx ->
when (destination) {
HomeDestination.Home -> HomeComponentFactory.createComponent(ctx, navigation)
HomeDestination.Detail -> DetailComponentFactory.createComponent(ctx, navigation)
}
},
).asStateFlow()
}
Tip
This pattern is not enforced by the library. For simple nav-hosts with one or two screens, inline anonymous objects (as shown in the Parent component section) work fine. The consolidated approach pays off as the number of screens and cross-screen navigation grows.
Passing results between screens
Use ResultFlow<T> to send a value from a child screen back to its parent. Results are routed by a
stable key declared as a typed ResultKey<T>: the parent collects results for a key, the child sends
a result for the same key, and the navigation config carries the key itself (which serializes down to
just its name), never a live object.
Results survive configuration changes and process death. Each value is persisted into saved state
until it is collected once, so a result the child produced before the OS killed the process still
reaches the parent after it is recreated. This needs a one-time integration step: wiring the result
registry into your AppComponentContext, described in
Components → Durable navigation results.
Note
Results must be @Serializable, one-shot, and small. They ride in the Android saved-state
Bundle (~1 MB limit), so use them for ids and selections, not large payloads.
Declare keys as constants with ResultKey<T> so the type travels with the key and every key stays in
one place. ResultKey<T> is itself @Serializable, so the typed key rides inside the child's config:
the parent pushes it and the child reads it back fully typed, with no need to restate the type or
re-wrap a raw string.
object HomeResultKeys {
val Picker = ResultKey<String>("home.picker")
}
// Navigation config: carries the typed key (only its name is serialized)
@Serializable
data class PickerConfig(val resultKey: ResultKey<String>)
// In the parent nav-host
private val pickerResult = resultFlow(HomeResultKeys.Picker)
init {
lifecycle.doOnCreate {
pickerResult
.onEach { selected -> update(componentState) { copy(selection = selected) } }
.launchIn(lifecycleScope)
}
}
private fun openPicker() = stackNavigation.push(PickerConfig(HomeResultKeys.Picker))
// In the child (picker) component, which received the typed resultKey from its config
fun onItemSelected(item: String) = launchWithHandler {
resultFlow(resultKey).sendResult(item) // suspending; call from a coroutine
navigation.back()
}
Key uniqueness
Keys are application-wide, so each parent must use a unique key; namespace them per destination
("home.picker", not "picker"). Reusing the same child component across two navigation branches is
safe: the child only echoes back whatever resultKey its config was given. If two parents use the
same key while both are on the stack, collecting the second throws an IllegalStateException right
away instead of silently crossing results between branches. Only one collector per key can be active
at a time. For multiple concurrent instances of the same screen, build a per-instance key at push time
and put it in the config (ResultKey<String>("home.picker.$itemId")); only its name is serialized,
and the type still travels with the field. For a static single-instance key, the parent and child can
instead both reference the shared HomeResultKeys.Picker constant directly, with nothing in the
config.
ResultFlow API reference
ResultFlow<T> extends Flow<T> and adds suspend fun sendResult(item: T) to send a value back from
a child destination to a parent. Obtain a durable instance from a component with the resultFlow
extension on the component context.
Obtaining a ResultFlow
// From a typed key constant
val pickerResult = resultFlow(HomeResultKeys.Picker)
// Or from a per-instance key built at push time
val pickerResult = resultFlow(ResultKey<String>("home.picker.$itemId"))
Sent values live in the application-wide NavigationResultRegistry (exposed on
ArkitektComponentContext), which persists undelivered results into the root component's
StateKeeper. Collect in init { lifecycle.doOnCreate { ... } }: every time the parent is recreated
it re-attaches its collector and replays any result that arrived while it was gone.