Mobile Core Concepts
Navigation
Compose Navigation - NavController, type-safe routes, back stack mechanics, deep links, and per-destination ViewModel scoping. The contract every multi-screen Android app builds on top of.
Every non-trivial app has more than one screen, which makes navigation a first-class concern, not glue. Navigation owns three things that every later layer assumes: the back stack (which screens are alive, in what order), the route (the addressable identity of a destination, including its arguments), and the scope (which ViewModels survive which transitions). Get these wrong and you get the classic Android bugs: ViewModels leaking across screens, deep links landing on a bare destination with no back stack, or rotation tearing down state because the route was untyped. This page covers Compose Navigation - the framework primitives (NavController, NavHost, NavGraph), the type-safe route model introduced in Navigation 2.8 (and where Navigation 3 is heading), back-stack mechanics, deep-link wiring, ViewModel scoping per NavBackStackEntry, and the pitfalls that catch even mid-level engineers. Deep links as a system primitive (<intent-filter>, App Links verification) live in the IPC Core Concept; this page treats them from the Compose Nav side.
The three primitives
Compose Navigation is three things working together:
NavController- the engine. Owns the back stack, dispatches navigation commands, exposes the current entry as state.NavHost- the renderer. A Composable that displays whichever destination the controller currently points at, with transitions between them.NavGraph- the routing table. Declares which routes exist, what arguments each takes, and what Composable renders each one.
@Composable
fun AppNav() {
val controller = rememberNavController()
NavHost(navController = controller, startDestination = "feed") {
composable("feed") {
FeedScreen(onItemClick = { id -> controller.navigate("detail/$id") })
}
composable("detail/{itemId}") { entry ->
DetailScreen(itemId = entry.arguments?.getString("itemId")!!)
}
}
}The graph is declarative: you describe the destinations once, and the NavController figures out which one to render based on the current back stack. There is no manual Activity.startActivity, no Intent extras, no FragmentTransaction. The Composable that renders a destination is just a function of its NavBackStackEntry.
Type-safe routes (Navigation 2.8+)
The string-route form above has the classic problems: typos compile, argument names and types are stringly enforced, and a missing argument fails at runtime instead of compile time. Navigation 2.8 introduced type-safe routes using kotlinx.serialization (apply the kotlin("plugin.serialization") Gradle plugin to use them): routes are Kotlin objects, arguments are properties, and the compiler enforces both ends.
@Serializable data object Feed // no args
@Serializable data class Detail(val itemId: String) // one arg
@Composable
fun AppNav() {
val controller = rememberNavController()
NavHost(controller, startDestination = Feed) {
composable<Feed> {
FeedScreen(onItemClick = { id -> controller.navigate(Detail(itemId = id)) })
}
composable<Detail> { entry ->
val detail: Detail = entry.toRoute()
DetailScreen(itemId = detail.itemId)
}
}
}Two things changed: the route literal "detail/$id" became Detail(itemId = id), and entry.arguments?.getString("itemId")!! became entry.toRoute<Detail>().itemId. The compiler now catches argument-shape mismatches, and toRoute() deserialization is the single boundary where route arguments enter your code.
Navigation 3 is the in-progress successor that drops NavHost and the graph DSL entirely in favor of a Compose-native back stack you manage as a mutableStateListOf<Route>. It's positioned as more idiomatic for Compose but is still in alpha as of late 2025; type-safe routes in 2.8 are the production-ready answer until 3 stabilizes.
Back stack mechanics
The back stack is an ordered list of NavBackStackEntrys, each one a destination + its arguments + its SavedStateHandle. navigate(...) pushes; popBackStack(...) pops. The interesting commands are the modifiers on navigate:
| Modifier | Effect |
|---|---|
popUpTo(route) { inclusive = true } | Pops everything above and including route before pushing the new destination |
popUpTo(route) { inclusive = false } | Pops everything above route, leaves route on the stack |
launchSingleTop = true | If the new destination matches the top of the stack, reuse it instead of pushing a duplicate |
restoreState = true | When re-pushing a destination, restore the saved state from the last time it was on the stack |
// Bottom-nav tab switch: pop to the start destination, reuse if already on top,
// restore the tab's saved scroll position. The canonical bottom-nav pattern.
controller.navigate(Profile) {
popUpTo(controller.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}The saveState / restoreState pair is what makes bottom-nav tabs survive switching. Without it, each tab switch rebuilds the destination from scratch and any in-progress scroll, search query, or expanded section is lost.
popUpTo(...) { inclusive = true } plus the new navigate(...) is also how you implement "log in then return to where you were trying to go": navigate to Login, on success popUpTo(Login) { inclusive = true } + navigate(originalDestination).
LaunchedEffect, not the Composable body - calling controller.navigate(Login) directly while composing fires on every recomposition and can loop. Gate redirects with LaunchedEffect(isAuthed) { if (!isAuthed) controller.navigate(Login) { popUpTo<CurrentRoute> { inclusive = true } } } so the side effect runs once per state change and the protected destination doesn't linger on the back stack.Route arguments and saved state
Each NavBackStackEntry has its own SavedStateHandle, accessed inside a ViewModel constructor. This is the bus for both route arguments and state that needs to survive process death.
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
repo: ItemRepository,
) : ViewModel() {
private val itemId: String = savedStateHandle.toRoute<Detail>().itemId
val state: StateFlow<UiState> = repo.observe(itemId)
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState.Loading)
}savedStateHandle.toRoute<Detail>() materializes the route's arguments back into the typed object - no getString("itemId") strings, no nullability gymnastics. Because route arguments go through serialization, pass IDs and look up the object in the ViewModel - never the object itself, which bloats the back stack and re-serializes on every navigation. For state the user types into the screen (search box, draft text), write it back into the handle:
val query: StateFlow<String> = savedStateHandle.getStateFlow("query", "")
fun onQueryChanged(q: String) { savedStateHandle["query"] = q }SavedStateHandle-stored values survive process death via the system's saved-instance-state bundle. Anything else - in-memory MutableStateFlows, view-model fields - is lost on kill. The split is the same as rememberSaveable vs remember in Compose, just one layer up.
Returning results to a previous destination
Often a destination needs to send a result back to the screen that opened it - a filter picker, a contact selector, an image-picker chooser. Compose Navigation doesn't have an onActivityResult analog; the idiom is to write into the previous entry's SavedStateHandle, then pop.
// Picker: write into the previous entry, then pop back.
composable<Picker> {
PickerScreen(onSelect = { id ->
controller.previousBackStackEntry
?.savedStateHandle
?.set("pickedId", id)
controller.popBackStack()
})
}
// Caller: observe its own savedStateHandle as a Flow.
composable<Composer> { entry ->
val pickedId by entry.savedStateHandle
.getStateFlow<String?>("pickedId", null)
.collectAsStateWithLifecycle()
LaunchedEffect(pickedId) {
if (pickedId != null) {
handlePick(pickedId)
entry.savedStateHandle["pickedId"] = null // consume so it doesn't re-fire
}
}
ComposerScreen(pickedId = pickedId, onPickClick = { controller.navigate(Picker) })
}Two properties that earn the pattern: the caller observes a StateFlow, so the result arrives as a normal state update with no cross-screen callback wiring; and because the value sits in SavedStateHandle, it survives process death between "user picked" and "caller consumed". The consume step (set(key, null)) is essential - without it, returning to the caller via any future Back press would re-fire the same result.
ViewModel scoping per NavBackStackEntry
A ViewModel retrieved inside a composable<X> is scoped to that destination's NavBackStackEntry. Each entry is its own LifecycleOwner, ViewModelStoreOwner, and SavedStateRegistryOwner - that's the substrate behind everything else in this section: per-entry VM scoping, per-entry SavedStateHandle, and collectAsStateWithLifecycle correctly pausing collection when a destination is buried under another one on the stack. The ViewModel survives as long as the entry is on the back stack - including when newer destinations are pushed on top of it - and is cleared only when the entry is popped (Back button, popUpTo { inclusive = true }).
composable<Detail> { entry ->
// Hilt resolves the ViewModel against `entry` automatically.
val vm: DetailViewModel = hiltViewModel()
DetailScreen(state = vm.state.collectAsStateWithLifecycle().value)
}Retrieving a screen's ViewModel via plain viewModel() without an explicit owner falls back to the Activity scope, so two unrelated screens silently share state. hiltViewModel() scopes to the NavBackStackEntry; for shared multi-step flows, scope to a parent nested-graph entry.
Implication: anything stored only in the ViewModel disappears with the pop. If state needs to outlive being popped (or process death), save it. SavedStateHandle is the route-scoped store described above - route arguments arrive through it, and anything written via savedStateHandle["key"] = value survives both pops and process kill.
Nested graphs
A nested graph is a sub-graph inside the main NavHost graph - a named group of destinations with its own start destination, treated as a single navigable unit. It's the right shape any time several destinations belong to the same flow: an onboarding sequence (welcome → permissions → account), a checkout (cart → address → payment → confirm), a bottom-nav tab with its own internal pages.
@Serializable data object OnboardingGraph
@Serializable data object Welcome
@Serializable data object Permissions
@Serializable data class Account(val email: String?)
NavHost(controller, startDestination = OnboardingGraph) {
navigation<OnboardingGraph>(startDestination = Welcome) {
composable<Welcome> { WelcomeScreen(...) }
composable<Permissions> { PermissionsScreen(...) }
composable<Account> { AccountScreen(...) }
}
composable<Feed> { FeedScreen(...) }
}Three properties earn the nesting:
- Shared ViewModel scope. Every destination inside the graph can retrieve the same parent-entry ViewModel - the natural home for multi-step state (the half-filled form, the in-progress upload, the partial OT op buffer). The parent entry lives until the whole graph is popped.
val parentEntry = remember(entry) { controller.getBackStackEntry(OnboardingGraph) }
val onboardingVm: OnboardingViewModel = hiltViewModel(parentEntry)-
Atomic dismissal.
popUpTo(OnboardingGraph) { inclusive = true }exits the entire flow in one call - whether the user is on step 2 or step 5. The alternative (pop each destination individually) is fragile and hard to reason about. -
Deep-link target as a flow, not a screen. A deep link landing on the graph enters at its start destination with the rest of the flow's back stack reconstructable, instead of dropping the user on a deep screen with nothing under it.
Bottom-nav apps are the canonical case: each tab is its own nested graph, so switching tabs preserves the tab's internal navigation state independently. Nesting graphs just to namespace routes is over-engineering - they earn their place when one of the three properties above is essential.
Deep links
A deep link is an external Intent that lands on a specific destination in your app, with the back stack synthesized to make Back behave correctly. Compose Navigation wires this in via deepLink { } on a composable<X>:
composable<Detail>(
deepLinks = listOf(navDeepLink { uriPattern = "https://app.example.com/items/{itemId}" }),
) { entry ->
val detail: Detail = entry.toRoute()
DetailScreen(itemId = detail.itemId)
}The matched URI's path parameters are mapped to route arguments by name. When app.example.com/items/42 is clicked, the system delivers an Intent to your Activity; the NavController matches the URI against the graph; the Detail(itemId = "42") destination is pushed with Feed underneath so Back returns to the feed instead of leaving the app.
The system-side wiring - declaring <intent-filter> in the manifest, verifying domain ownership via Digital Asset Links for App Links, choosing between custom schemes and verified web URLs - is covered in the IPC Core Concept. From the Compose Nav side, the contract is: declare the pattern on the destination, name the path parameters to match route arguments, and the back stack is reconstructed automatically.
Scaffolding around the NavHost
A real app wraps the NavHost in a Scaffold that hosts the top bar, bottom bar, snackbar host, and FAB. The pattern that scales:
@Composable
fun AppNav() {
val controller = rememberNavController()
val currentEntry by controller.currentBackStackEntryAsState()
Scaffold(
bottomBar = { AppBottomBar(currentEntry, onTabClick = { ... }) },
snackbarHost = { /* one SnackbarHost shared across screens */ },
) { padding ->
NavHost(controller, startDestination = Feed, modifier = Modifier.padding(padding)) {
composable<Feed> { FeedScreen(...) }
composable<Detail> { entry -> DetailScreen(entry.toRoute<Detail>().itemId) }
}
}
}currentBackStackEntryAsState() is the bridge between navigation state and Compose state - the top-level Composable observes the current route and updates the bottom bar's selection without each screen having to know it's hosted under a bottom nav. Per-screen scaffolding (a per-destination top bar) goes inside the destination's Composable, not outside the NavHost.
Transitions
By default, Compose Navigation cross-fades between destinations. To replace that with slide-style motion (the platform default people expect), set transition lambdas per composable<X>:
composable<Detail>(
enterTransition = { slideInHorizontally { it } }, // forward: new screen enters from the right
exitTransition = { slideOutHorizontally { -it } }, // forward: old screen slides off to the left
popEnterTransition = { slideInHorizontally { -it } }, // back: previous screen re-enters from the left
popExitTransition = { slideOutHorizontally { it } }, // back: current screen slides off to the right
) { entry ->
DetailScreen(entry.toRoute<Detail>().itemId)
}The four hooks correspond to the four moments the system has to animate: entering forward, exiting forward, entering on Back, exiting on Back. Most apps set one or two and let the rest mirror naturally. Setting them as defaults on the NavHost itself (the enterTransition = { ... } argument) keeps the whole graph consistent without per-destination repetition.
Long animations on slow devices are a perceived-perf trap - keep transitions under ~250 ms or readers will reach for the Back button before yours finishes.
See also
- State Management -
SavedStateHandleandrememberSaveableare the same primitives; navigation just hosts them per back stack entry. - Compose - the rendering model that
NavHostplugs into; Composables collect navigation state viacollectAsStateWithLifecycle. - Concurrency -
viewModelScopeis what's cancelled when a destination leaves the back stack; navigation transitions and coroutine cancellation are the same lifecycle event. - IPC - covers the system side of deep links (
<intent-filter>, App Links, custom schemes); this page covers the Compose Nav side.
Used in
- The Android App Template - the read/write/push paths all assume per-destination ViewModel scoping; FCM push deep-links into
Detailwith a synthesized back stack. - Messenger App - thread list to chat is the canonical nested-graph case; the chat composer's draft survives back-stack pops via
SavedStateHandle. - Newsfeed App - bottom-nav tab restoration is
saveState/restoreState; each tab owns its own scroll position and feed cursor. - Doc Editor App - shared
EditorViewModellives on a nested-graph parent entry so the editor, formatting panel, and presence sidebar coordinate through one instance.
Done reading? Mark it so it sticks in your dashboard.