androidengineers.Book a session

Jetpack Compose System Design

Compose Navigation & ViewModel

article20 minMedium

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

ConceptRule
Route definitionSealed class — prevents typos and provides refactor safety
viewModel()Scoped to the NavBackStackEntry by default in a composable destination
Shared ViewModelUse viewModel(parentBackStackEntry) for flows spanning multiple screens
HilthiltViewModel() replaces viewModel() — no factory needed
SavedStateHandleReceives route arguments as keys; survives process death
Deep linksDeclare in composable() with navDeepLink { uriPattern = "..." }

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Compose Navigation & ViewModel | Android System Design | Android Engineers