Skip to content
Commitwise
Menu
Android Development

Navigation

Move between Compose screens with Navigation Compose and type-safe routes, pass ids instead of objects, return results, control the back stack for login flows and bottom navigation, and open screens from deep links.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The user logs in, lands on the home screen, presses Back, and is on the login screen again, still logged in. Another user taps the Profile tab three times and has to press Back three times to leave it. A third opens a product from a link in an email and gets a screen with no way back. Navigation bugs are rarely crashes; they’re an app that feels broken. Most of them come down to one thing: what’s on the back stack.

New to some of these? Open what you need, or skip ahead.

Basics:What is a composable function?

In Jetpack Compose, UI is written as functions marked @Composable:

@Composable
fun Greeting(name: String) {
    Text("Hello, $name!")
}

A composable describes what the screen should show for the data it receives. It doesn’t return a view; it emits UI elements. When the data changes, Compose calls the function again (recomposition) and updates only what changed. Composables are combined by calling each other inside layouts like Column and Row.

Basics:What is a stack?

A stack is a list where you only add and remove at the top, like a pile of plates: the last plate you put on is the first one you take off. Android keeps the screens you’ve visited on a back stack: opening a screen pushes it on top, and pressing Back pops the top one off, showing the one underneath.

Basics:What is a ViewModel?

A ViewModel is an object that holds a screen’s state and logic, separate from the UI code. On Android, the system keeps the same ViewModel instance when the Activity is recreated after a rotation, so data survives. The ViewModel and unidirectional data flow tutorial covers it in detail.

Part Not started

Routes and the NavHost

A building’s floor plan lists every room and the doors between them. You don’t carry furniture from room to room; you walk through a door, and the room you enter has what it needs.

In Navigation Compose, the NavHost is the floor plan: a list of routes (the rooms) and which screen each one shows. navController.navigate(route) walks through a door, putting the new screen on top of the back stack; popBackStack() (or the system Back gesture) returns to the previous one.

Navigation Compose keeps a NavBackStackEntry per destination: it’s the LifecycleOwner, ViewModelStoreOwner and SavedStateRegistryOwner for that screen, which is why destination-scoped ViewModels and saved state work per entry. Since 2.8, routes are @Serializable types; arguments are encoded into the entry’s saved state, so they survive process death and are type-checked at compile time. Keep arguments small (ids, filters) and resolve data in the destination.

@Serializable object Home
@Serializable data class ProductDetail(val productId: String)
@Serializable object Cart

@Composable
fun ShopNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController, startDestination = Home) {
        composable<Home> {
            HomeScreen(
                onProductClick = { id -> navController.navigate(ProductDetail(id)) },
                onCartClick = { navController.navigate(Cart) },
            )
        }
        composable<ProductDetail> { entry ->
            val route: ProductDetail = entry.toRoute()
            ProductScreen(productId = route.productId, onBack = { navController.popBackStack() })
        }
        composable<Cart> {
            CartScreen(onBack = { navController.popBackStack() })
        }
    }
}
  • Each route is a Kotlin type marked @Serializable: object for screens without arguments, data class for screens that need some.
  • composable<ProductDetail> { … } says which screen to show for that route; entry.toRoute() reads its arguments back, already typed.
  • Screens don’t receive the navController itself; they get small callbacks like onProductClick. That keeps screens reusable and easy to preview and test.

Pass ids, not objects

class ProductViewModel(
    savedStateHandle: SavedStateHandle,
    private val repository: ProductRepository,
) : ViewModel() {
    private val productId = savedStateHandle.toRoute<ProductDetail>().productId   // the route's argument

    val product: StateFlow<Product?> = repository.observeProduct(productId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

The route carries only productId. The product screen’s ViewModel loads (or observes) the product itself. That way the screen always shows current data (if the price changed while the user was browsing, they see the new one), it works when opened from a link or a notification that only knows the id, and the back stack stays small.

Spot the bugThe app opens Home again and again

Tap the line with the bug.

@Composable
fun LoginScreen(viewModel: LoginViewModel, navController: NavController) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    if (state.loggedIn) {
        navController.navigate(Home)
    }
    LoginForm(state, onLogin = viewModel::logIn)
}

Predict the output

The user goes Home → Product 1 → Product 2 → Cart, then presses Back twice. Which screen is on top?

Practice: plan the graph

  1. List the screens and draw arrows for every way to get from one to another.
  2. Write a route type for each screen, with only the arguments it can’t load itself (usually an id).
  3. Decide what Back should do from every screen. Login and onboarding should usually disappear from the stack once done.
  4. Give screens callbacks (onProductClick, onBack), and keep all navController calls in the NavHost.

Scoping ViewModels to the graph

A destination’s ViewModel lives as long as its back stack entry: navigating away keeps it (the entry is still on the stack), popping clears it. For a multi-screen flow (checkout, onboarding), put the screens in a nested graph and scope a shared ViewModel to the graph’s entry: val parent = remember(entry) { navController.getBackStackEntry<CheckoutGraph>() }; val vm: CheckoutViewModel = viewModel(parent). It’s created when the flow starts and cleared when the whole flow is popped, which is exactly the lifetime the flow’s state needs.

  • @Serializable routes (Navigation 2.8+), composable<T>, toRoute() in the entry or via SavedStateHandle.toRoute() in the ViewModel; arguments survive process death.
  • Pass ids; destinations load/observe their data. Screens get callbacks, not the NavController.
  • Navigation is a side effect: trigger from events or LaunchedEffect, never from composition.
  • Each NavBackStackEntry owns lifecycle, ViewModels and saved state; share state across a flow with a graph-scoped ViewModel.

Check this part

  1. What should a route to a product screen contain?

  2. Why not call navController.navigate directly in a composable's body?

  3. How do you share a ViewModel between the four screens of a checkout flow?

A NavHost maps routes to composables; navigate(route) pushes a screen, popBackStack() goes back. Since Navigation 2.8, routes are @Serializable Kotlin types, so arguments are type-checked. Pass ids, not whole objects, and load the data in the destination's ViewModel.

Part Not started

Shaping the back stack

// After login: go Home and remove Login from the back stack
navController.navigate(Home) {
    popUpTo<Login> { inclusive = true }
}

// Bottom navigation: switch tabs without stacking them, keeping each tab's state
fun NavController.switchTab(tab: Any) = navigate(tab) {
    popUpTo(graph.findStartDestination().id) { saveState = true }
    launchSingleTop = true
    restoreState = true
}

// Don't open the same screen twice on a double tap
navController.navigate(Cart) { launchSingleTop = true }
  • popUpTo(X) removes every screen above X before adding the new one; with inclusive = true, X itself is removed too. After login or onboarding, that’s how the old screens disappear.
  • launchSingleTop doesn’t add the screen again if it’s already on top. A double tap no longer opens two Cart screens.
  • saveState / restoreState for bottom navigation: each tab keeps its own history and scroll position, and switching tabs doesn’t pile screens up.

Back stack semantics to keep in mind: popUpTo pops before pushing, and with saveState the popped entries’ state (including their ViewModels’ saved state and nested back stacks) is stored under the destination and restored by a later restoreState. launchSingleTop only compares against the current top entry. Predictive back (Android 14+, enabled by default for apps targeting 15+) animates the pop before it commits, so destinations must tolerate being partially revealed and cancelled.

Returning a result

// In the picker, when the user taps an address:
navController.previousBackStackEntry
    ?.savedStateHandle
    ?.set("picked_address_id", address.id)
navController.popBackStack()

// In the checkout screen (the entry the picker returns to):
val pickedId by navController.currentBackStackEntry!!
    .savedStateHandle
    .getStateFlow<String?>("picked_address_id", null)
    .collectAsStateWithLifecycle()

LaunchedEffect(pickedId) {
    pickedId?.let { viewModel.onAddressPicked(it) }
}

The picker writes its result into the previous screen’s saved state, then pops itself. The checkout screen observes that value and reacts. Because it’s saved state, the result survives rotation and process death. For results that cross a whole flow, a shared, graph-scoped ViewModel is often simpler.

Exerciseeasy

Onboarding that doesn't come back

An app has Welcome → Permissions → Home. After the user finishes onboarding, Back on Home should close the app, not show Permissions or Welcome. Write the navigation call from the Permissions screen to Home.

›

You want to remove everything up to and including the first onboarding screen, then show Home.

SolutionTry it yourself first
navController.navigate(Home) {
    popUpTo<Welcome> { inclusive = true }   // remove Welcome and everything above it
    launchSingleTop = true
}

The back stack becomes just Home, so Back finishes the Activity. Also store “onboarding done” in DataStore, and pick the start destination from it at launch (startDestination = if (onboarded) Home else Welcome), so a relaunch doesn’t show onboarding again.

Production puzzles

Spot the bugTabs that pile up

Tap the line with the bug.

@Composable
fun MainScaffold(navController: NavHostController) {
    val tabs = listOf(HomeTab, SearchTab, ProfileTab)
    Scaffold(bottomBar = {
        NavigationBar {
            tabs.forEach { tab ->
                NavigationBarItem(
                    selected = currentTab(navController) == tab,
                    onClick = { navController.navigate(tab) },
                    icon = { Icon(tab.icon, contentDescription = tab.label) },
                )
            }
        }
    }) { padding -> MainNavHost(navController, Modifier.padding(padding)) }
}

  • popUpTo<T> { inclusive } for flows that must leave the stack (login, onboarding); launchSingleTop against duplicates; bottom nav = popUpTo(startDestination) { saveState = true } + launchSingleTop + restoreState.
  • Results: write to previousBackStackEntry.savedStateHandle, observe via getStateFlow in the receiver (process-death safe), or use a graph-scoped ViewModel.
  • Predictive back: pops may be previewed and cancelled; don’t run irreversible work on “about to pop”.

Check this part

  1. After login, Back on Home shows the login screen. What fixes it?

  2. What does launchSingleTop prevent?

  3. Bottom navigation tabs lose their scroll position and pile up on the back stack. What's missing?

popUpTo removes screens from the back stack when navigating (inclusive removes the target too), launchSingleTop avoids duplicates, and saveState/restoreState keep each bottom-navigation tab's own history. Results go back through the previous entry's SavedStateHandle.

Key takeaways

  • A NavHost maps routes to screens; navigate pushes onto the back stack and Back pops.
  • Routes are @Serializable Kotlin types: pass ids and let the destination load its data.
  • Navigate from click handlers or a LaunchedEffect, never directly in a composable’s body.
  • Use popUpTo after login and onboarding, and the save/restore options for bottom navigation.
  • Back stack entries own lifecycle, ViewModels and saved state; scope shared flow state to a nested graph.
  • Results go through SavedStateHandle of the previous entry or a graph-scoped ViewModel.
  • Bottom navigation needs popUpTo(start) { saveState }, launchSingleTop, restoreState.
  • Deep links are untrusted entry points: validate, authorize at the destination, rebuild the back stack.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.