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:objectfor screens without arguments,data classfor 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
navControlleritself; they get small callbacks likeonProductClick. 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.
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)
}Hint: How many times does Compose call LoginScreen while state.loggedIn is true?
A composable function runs on every recomposition, which can happen many times a second (animations, the keyboard, any state change). Every run while loggedIn is true calls navigate(Home) again, stacking Home screens on top of each other, and Login stays underneath, so Back returns to it.
Navigation is a side effect: run it once, when the condition becomes true, and remove Login from the back stack:
@Composable
fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.loggedIn) {
if (state.loggedIn) onLoggedIn() // once per change of loggedIn
}
LoginForm(state, onLogin = viewModel::logIn)
}
// In the NavHost:
composable<Login> {
LoginScreen(viewModel(), onLoggedIn = {
navController.navigate(Home) {
popUpTo<Login> { inclusive = true } // Back from Home no longer shows Login
}
})
}Predict the output
The user goes Home → Product 1 → Product 2 → Cart, then presses Back twice. Which screen is on top?
Each navigate pushes a screen: the stack is Home, Product 1, Product 2, Cart (top). Back pops Cart (now Product 2 is on top), Back again pops Product 2: Product 1 is showing. Picturing the stack like this is the fastest way to reason about any navigation bug.
Practice: plan the graph
- List the screens and draw arrows for every way to get from one to another.
- Write a route type for each screen, with only the arguments it can’t load itself (usually an id).
- Decide what Back should do from every screen. Login and onboarding should usually disappear from the stack once done.
- Give screens callbacks (
onProductClick,onBack), and keep allnavControllercalls 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.
@Serializableroutes (Navigation 2.8+),composable<T>,toRoute()in the entry or viaSavedStateHandle.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
NavBackStackEntryowns lifecycle, ViewModels and saved state; share state across a flow with a graph-scoped ViewModel.
Check this part
What should a route to a product screen contain?
Why not call navController.navigate directly in a composable's body?
How do you share a ViewModel between the four screens of a checkout flow?