Navigation and screen state
Lesson 4 of 51 min read
Routes, arguments, back stack, and not passing objects between screens.
Navigation in Compose is a graph of routes. Each destination is a composable, and arguments travel in the route.
NavHost(navController, startDestination = "feed") {
composable("feed") {
FeedScreen(onProductClick = { id -> navController.navigate("product/$id") })
}
composable(
route = "product/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
) { ProductScreen() } // the ViewModel reads id from SavedStateHandle
}
Pass ids, not objects
It is tempting to pass the whole product. Do not: a Bundle has a size limit, the object can be stale by the time the screen opens, and process death will not restore it. Pass the id and let the destination's repository provide the data — that also makes the screen deep-linkable for free.
Reading arguments
class ProductViewModel(
savedState: SavedStateHandle,
repo: ProductRepository,
) : ViewModel() {
private val id: String = checkNotNull(savedState["id"])
val state = repo.observe(id).map(::toUiState).stateIn(...)
}
Back stack behaviour worth knowing
popUpTowithinclusive— the login-then-home case, so Back does not return to login.launchSingleTop— stops a double tap creating two copies of the same screen.- A ViewModel scoped to a destination is cleared when that destination leaves the back stack — which is what you want, and a surprise if you expected it to be shared.
Events that cause navigation
Navigation is an event, not state. Emit it once from a SharedFlow or Channel and handle it in a LaunchedEffect. If you keep it in state, a rotation re-reads the state and navigates a second time.
The usual mistake: passing the
NavControllerdeep into child composables. Pass lambdas —onProductClick: (String) -> Unit— and the children stay previewable and testable.
