Navigation Compose provides a type-safe way to navigate between screens while keeping each screen's ViewModel scoped correctly to its lifecycle. Getting ViewModel scoping right is one of the most common sources of bugs in Compose apps.
Setup
// implementation("androidx.navigation:navigation-compose:2.7.7")
// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
// implementation("androidx.hilt:hilt-navigation-compose:1.2.0") // if using Hilt
Defining Routes
Use a sealed class or object for type safety:
sealed class Screen(val route: String) {
object ArticleList : Screen("article_list")
data class ArticleDetail(val articleId: String) : Screen("article_detail/{articleId}") {
companion object {
fun createRoute(id: String) = "article_detail/$id"
}
}
object Settings : Screen("settings")
}
NavHost: Declaring the Graph
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.ArticleList.route
) {
composable(Screen.ArticleList.route) {
ArticleListScreen(
onArticleClick = { id ->
navController.navigate(Screen.ArticleDetail.createRoute(id))
}
)
}
composable(
route = Screen.ArticleDetail.route,
arguments = listOf(navArgument("articleId") { type = NavType.StringType })
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: return@composable
ArticleDetailScreen(
articleId = articleId,
onBack = { navController.popBackStack() }
)
}
composable(Screen.Settings.route) {
SettingsScreen()
}
}
}
ViewModel Scoping
This is the most important part — ViewModel lifetime must match the screen's lifetime:
// ✅ Correct: viewModel() gives a ViewModel scoped to this back stack entry
@Composable
fun ArticleDetailScreen(articleId: String) {
val viewModel: ArticleDetailViewModel = viewModel()
// This ViewModel is created when ArticleDetailScreen enters the back stack
// and destroyed when it leaves — correct!
}
// ❌ Wrong: viewModel() at the NavHost's composable gives a ViewModel
// scoped to the ACTIVITY — survives navigation away and back
// Don't do this if the ViewModel holds screen-specific state
Scoping ViewModel to Back Stack Entry
For shared ViewModels between screens in the same flow:
@Composable
fun CheckoutFlowScreen() {
val navController = rememberNavController()
NavHost(navController, startDestination = "cart") {
composable("cart") {
val parentEntry = remember(it) {
navController.getBackStackEntry("checkout_graph") // parent graph
}
val sharedViewModel: CheckoutViewModel = viewModel(parentEntry)
CartScreen(viewModel = sharedViewModel)
}
composable("payment") {
val parentEntry = remember(it) {
navController.getBackStackEntry("checkout_graph")
}
val sharedViewModel: CheckoutViewModel = viewModel(parentEntry)
PaymentScreen(viewModel = sharedViewModel)
}
}
}
Hilt + Navigation Compose
// With Hilt — no factory boilerplate needed
@Composable
fun ArticleDetailScreen(articleId: String) {
val viewModel: ArticleDetailViewModel = hiltViewModel()
// Hilt injects dependencies into the ViewModel automatically
// ViewModel is scoped to this back stack entry
}
// ViewModel with SavedStateHandle — receives route arguments automatically
@HiltViewModel
class ArticleDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: ArticleRepository
) : ViewModel() {
private val articleId: String = savedStateHandle["articleId"]!!
val article = repository.getArticle(articleId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
Deep Links
composable(
route = Screen.ArticleDetail.route,
arguments = listOf(navArgument("articleId") { type = NavType.StringType }),
deepLinks = listOf(
navDeepLink {
uriPattern = "https://androidengineers.in/articles/{articleId}"
}
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId")!!
ArticleDetailScreen(articleId)
}
Back Navigation and Results
// Pass result back from a screen
fun onColorSelected(color: Color) {
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_color", color.toArgb())
navController.popBackStack()
}
// Observe result in the calling screen
@Composable
fun ColorPickerCaller() {
val navController = LocalNavController.current
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val selectedColor = savedStateHandle
?.getStateFlow<Int?>("selected_color", null)
?.collectAsStateWithLifecycle()
// React to selectedColor
}
Key Takeaways
| Concept | Rule |
|---|---|
| Route definition | Sealed class — prevents typos and provides refactor safety |
viewModel() | Scoped to the NavBackStackEntry by default in a composable destination |
| Shared ViewModel | Use viewModel(parentBackStackEntry) for flows spanning multiple screens |
| Hilt | hiltViewModel() replaces viewModel() — no factory needed |
SavedStateHandle | Receives route arguments as keys; survives process death |
| Deep links | Declare in composable() with navDeepLink { uriPattern = "..." } |