Android – Navigation Compose

August 20, 20268 min readUpdated 10/11/2026

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's rememberSaveable state, keyed by the tab.
  • restoreState = true brings back whatever was saved for the tab being opened.
  • launchSingleTop stops 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".

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 NavController anywhere but the root. rememberNavController() belongs next to the NavHost; 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).

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.