An app with one Activity still has many screens, and something has to know which one is showing,
what is underneath it, and what Back does. In Compose that is the Navigation library: a
NavHost that maps routes to composables, and a NavController that holds the
back stack.
This lesson is the modern shape of it — routes as types, a graph per tab, and the handful of navigation calls that decide whether Back does what a customer expects.
Routes are types
For years a route was a string like "order/{orderId}", and an argument was parsed back
out of it. A typo, a missing argument or a renamed screen was a crash at runtime. Since Navigation 2.8
a route is any @Serializable class:
// Tab roots
@Serializable data object HomeRoute
@Serializable data object MenuRoute
@Serializable data object OrdersRoute
@Serializable data object ProfileRoute
// Pushed on top of whichever tab is selected
@Serializable data object CheckoutRoute
@Serializable data class OrderRoute(val orderId: UuidString)
// One nested graph per tab — the unit Navigation saves and restores when the customer switches tabs.
@Serializable data object HomeGraph
@Serializable data object MenuGraph
@Serializable data object OrdersGraph
@Serializable data object ProfileGraph
A data object is a route with no arguments. A data class is a route with
arguments, and navigating to it is a constructor call the compiler checks:
navController.navigate(OrderRoute(orderId)). The library serialises the properties into
the back stack and the destination reads them back as the same type.
Note what OrderRoute carries: an id, not an Order. The back stack is saved
into a Bundle when the process is killed in the background (the previous lesson), and an
id survives that where a whole order would not fit — and would be stale by the time it was restored
anyway. The screen fetches the order it needs.
The graph
A NavHost declares every destination, starting from a start destination:
NavHost(navController = navController, startDestination = HomeGraph, modifier = modifier) {
navigation<HomeGraph>(startDestination = HomeRoute) {
composable<HomeRoute> { HomeScreen(onOrderNow = { navController.navigateToTab(AppTab.MENU) }) }
}
navigation<MenuGraph>(startDestination = MenuRoute) {
composable<MenuRoute> { MenuScreen() }
}
navigation<OrdersGraph>(startDestination = OrdersRoute) {
// …
composable<MenuRoute> { … } registers a screen for a route type.
navigation<MenuGraph>(startDestination = MenuRoute) groups screens into a
nested graph with its own start. Each tab is one nested graph, and the reason is the next
section.
Checkout and the receipt are registered at the top level, outside any tab:
composable<CheckoutRoute> {
CheckoutScreen(
onOrderPlaced = { orderId ->
/*
* Replace checkout with the receipt rather than pushing on top of it: the back
* button must not return to a checkout for an order that is already paid.
*/
navController.navigate(OrderRoute(orderId)) {
popUpTo<CheckoutRoute> { inclusive = true }
}
},
onBrowseMenu = { navController.navigateToTab(AppTab.MENU) },
)
}
composable<OrderRoute> { OrderConfirmationScreen() }
They can be reached from any tab, and are pushed onto whichever tab the customer is in.
Screens do not navigate
Look at what the screens receive: onOrderPlaced, onBrowseMenu,
onSignIn. Never the NavController. A screen reports what happened; the graph
decides where that leads. That keeps CheckoutScreen previewable and testable without a
navigation graph, and it means a change to the app's flow is an edit to one file, not a hunt through
every screen that might navigate.
Tabs that remember where they were
A bottom bar is a row of NavigationBarItems. Selecting one navigates to that tab's
graph — and the options on that call are the difference between tabs that behave and tabs that do
not:
private fun NavHostController.navigateToTab(tab: AppTab) {
navigate(tab.graph) {
popUpTo(graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
popUpTo(start) { saveState = true }pops the current tab's screens off the stack — but saves them, including every screen'srememberSaveablestate, keyed by the tab.restoreState = truebrings back whatever was saved for the tab being opened.launchSingleTopstops a second tap on the current tab from stacking a duplicate copy of it.
Together: open an order on the Orders tab, switch to Menu, switch back, and the order is still
open. Leave out either state flag and every tab switch silently resets the tab. This is what the
per-tab graphs are for — they are the unit that gets saved and restored. (The iOS app keeps four
separate NavigationPaths for the same reason; Navigation Compose does the bookkeeping
for you, if you ask.)
Which tab is selected
Highlighting the right tab is subtler than it looks, because checkout is pushed on top of whichever tab the customer was in — so the destination alone cannot say which tab to highlight. The app keeps the selection as its own saved state, and lets the back stack override it when the destination does belong to a tab:
var selectedTab by rememberSaveable { mutableStateOf(AppTab.HOME) }
LaunchedEffect(destination) {
AppTab.entries.firstOrNull { destination.isIn(it) }?.let { selectedTab = it }
}
NavDestination.hierarchy walks from a screen up through its parent graphs, so "is this
destination inside MenuGraph?" is one line:
private fun NavDestination?.isIn(tab: AppTab): Boolean =
this?.hierarchy?.any { it.hasRoute(tab.graph::class) } == true
Pressing Back from a tab's root returns to the Home tab — the start destination of the whole
graph — and the LaunchedEffect moves the highlight with it.
Reading the current destination
The top bar's title and back arrow depend on where the customer is.
currentBackStackEntryAsState() turns the controller's back stack into Compose state, so
the bar recomposes when the destination changes:
val backStackEntry by navController.currentBackStackEntryAsState()
val destination = backStackEntry?.destination
private fun titleFor(destination: NavDestination?): String = when {
destination == null -> "StayHub Pizza"
destination.hasRoute(MenuRoute::class) -> "Menu"
destination.hasRoute(OrdersRoute::class) -> "Your orders"
destination.hasRoute(ProfileRoute::class) -> "Profile"
destination.hasRoute(CheckoutRoute::class) -> "Checkout"
destination.hasRoute(OrderRoute::class) -> "Your order"
else -> "StayHub Pizza"
}
hasRoute(OrderRoute::class) compares route types, which is how a typed route
with an argument is recognised without caring which order it is.
Arguments, on the other side
The receipt screen's ViewModel needs the order id. It does not get it as a parameter — Hilt builds
the ViewModel, and Hilt knows nothing about navigation. Navigation puts the route's properties into
the ViewModel's SavedStateHandle, and toRoute reads them back as the typed
route:
val orderId: String = savedStateHandle.toRoute<OrderRoute>().orderId
Because it comes from saved state, it survives process death with no extra work: the restored back stack hands the new ViewModel the same route.
Replacing a screen
After a successful payment the app shows the receipt. Pushing it on top of checkout would leave checkout underneath, and Back would return the customer to a checkout for an order they have already paid for. So the receipt replaces checkout:
navController.navigate(OrderRoute(orderId)) {
popUpTo<CheckoutRoute> { inclusive = true }
}
popUpTo<CheckoutRoute> { inclusive = true } pops everything above checkout
and checkout itself, then pushes the receipt. Back from the receipt goes to wherever the
customer was before checkout — the menu, typically. The same tool handles "sign out and return to the
start" and "finish onboarding and never show it again".
Navigating from a sheet
The cart is a bottom sheet over whatever screen the customer was on, and its button goes to checkout. The sheet is not a destination in the graph — it is state in the root — so the root handles both halves:
AppSheet.CART -> CartSheet(
onCheckout = {
viewModel.dismissSheet()
navController.navigate(CheckoutRoute) { launchSingleTop = true }
},
onDismiss = viewModel::dismissSheet,
)
Dismiss first, then navigate. And launchSingleTop = true on the navigation: a
customer who taps the button twice on a slow phone gets one checkout on the stack, not two, with the
second Back press revealing a duplicate. It is the navigation equivalent of disabling a submit button
while it is submitting.
The wrong way: navigating during composition
The most common navigation bug in Compose looks completely reasonable:
// ✗ runs on EVERY recomposition while completedOrderId is set
if (state.completedOrderId != null) {
navController.navigate(OrderRoute(state.completedOrderId))
}
A composable's body runs whenever Compose decides to run it — once, twice, ten times. Navigating
from the body pushes the receipt as many times as the screen recomposes before it leaves, and the
customer has to press Back five times to escape. Navigation is a side effect, so it belongs in an event
handler (an onClick) or in an effect keyed on the value that triggers it — with the
trigger cleared once acted on. This app's checkout does exactly that, and the payments lesson walks
through it.
Smaller mistakes worth avoiding
- Creating the
NavControlleranywhere but the root.rememberNavController()belongs next to theNavHost; a second one inside a screen is a second, unrelated back stack. - Passing objects through routes. Even when they serialise, they bloat the saved state and go stale. Pass the id.
- Sharing one ViewModel between destinations by accident.
hiltViewModel()called inside a destination is scoped to that destination. To share one deliberately across a flow, scope it to the parent graph's back-stack entry instead. - Hiding the bottom bar by checking routes in every screen. If some screens should not show it, decide that once in the root, from the current destination — exactly as the title is.
Back
The system Back gesture pops the NavHost's back stack automatically; you write nothing.
The top bar's arrow calls navController.navigateUp(), which does the same for an
in-app back button.
Two things worth knowing. When the back stack is at a tab's root, Back leaves the app — Android's
convention, unlike iOS where a tab root has nowhere to go. And Android 14+ shows a predictive
back animation, previewing the screen underneath as the gesture progresses; Navigation Compose
supports it out of the box, which is one more reason to let the library own Back rather than
intercepting it with your own BackHandler unless a screen really must (an unsaved-changes
prompt, say).
Deep links
Because routes are types, a deep link is a mapping from a URL pattern to a route:
composable<OrderRoute>(deepLinks = listOf(navDeepLink<OrderRoute>(basePath =
"https://pizza.example.com/order"))) plus an intent filter in the manifest. This app does not
expose one yet; the design — ids in routes, screens that fetch their own data — means adding it is a
line per screen, not a refactor.
Testing it
Because screens take lambdas instead of a controller, most navigation never needs a navigation test:
a screen test asserts that tapping "Browse the menu" called onBrowseMenu, and that is the
screen's whole responsibility. The graph itself — which lambda leads where — can be tested with a
TestNavHostController from navigation-testing, asserting on
currentBackStackEntry after a tap. Keeping all of it in one root file is what makes that
one test enough.
Next
Most navigation starts from a list. The next lesson is LazyColumn: why a plain
scrolling Column is the wrong tool for a menu, what keys are for, and how Compose decides
which rows it can skip.