Skip to content
All posts
July 1, 20263 min read

Jetpack Compose Navigation — My Setup After 10 Apps

Navigation in Compose has rough edges. After setting it up in 10+ apps, here is the pattern that avoids the common pain points — type-safe routes, bottom nav integration, and ViewModel scoping.

AndroidJetpack ComposeNavigationKotlin
Share:

Compose Navigation is not as clean as it should be. The API has improved with each release, but there are still decisions you make in every project that aren't obvious until you've made the wrong call once.

Here's the setup that works.

Type-safe routes with sealed classes

String-based routes are error-prone and not refactor-safe. Use a sealed class hierarchy instead:

kotlin
sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Profile : Screen("profile")
    data class Detail(val id: String) : Screen("detail/{id}") {
        fun createRoute(id: String) = "detail/$id"
    }
    object Settings : Screen("settings")
}

This gives you autocomplete, compile-time safety, and a single source of truth for all routes.

kotlin
@Composable
fun AppNavGraph(
    navController: NavHostController = rememberNavController()
) {
    NavHost(
        navController = navController,
        startDestination = Screen.Home.route
    ) {
        composable(Screen.Home.route) {
            HomeScreen(
                onNavigateToDetail = { id ->
                    navController.navigate(Screen.Detail("").createRoute(id))
                }
            )
        }
        composable(
            route = Screen.Detail("").route,
            arguments = listOf(navArgument("id") { type = NavType.StringType })
        ) { backStackEntry ->
            val id = backStackEntry.arguments?.getString("id") ?: return@composable
            DetailScreen(id = id)
        }
        composable(Screen.Settings.route) { SettingsScreen() }
        composable(Screen.Profile.route) { ProfileScreen() }
    }
}

Bottom navigation integration

The pattern for bottom nav that preserves back stack state per tab:

kotlin
@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination

    val bottomNavItems = listOf(Screen.Home, Screen.Profile, Screen.Settings)

    Scaffold(
        bottomBar = {
            NavigationBar {
                bottomNavItems.forEach { screen ->
                    NavigationBarItem(
                        selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
                        onClick = {
                            navController.navigate(screen.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = { /* icon */ },
                        label = { Text(screen.route) }
                    )
                }
            }
        }
    ) { paddingValues ->
        AppNavGraph(navController = navController)
    }
}

The

code
saveState = true
/
code
restoreState = true
pair is what preserves scroll position and ViewModel state when switching tabs. Skip it and your users will be confused why the tab resets every time.

ViewModel scoping to nav graph

For shared state between screens in the same flow, scope the ViewModel to the nav graph rather than individual screens:

kotlin
composable(Screen.Checkout.route) { backStackEntry ->
    val parentEntry = remember(backStackEntry) {
        navController.getBackStackEntry("checkout_graph")
    }
    val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
    CheckoutScreen(viewModel = sharedViewModel)
}

This means the ViewModel survives navigation between checkout steps but is cleared when the user exits the checkout flow entirely.

Passing complex objects

Don't pass complex objects through navigation arguments. Pass IDs, fetch data in the destination.

kotlin
// Wrong — serializing full objects is fragile
navController.navigate("detail/${json.encode(user)}")

// Right — pass the ID, fetch in the ViewModel
navController.navigate(Screen.Detail("").createRoute(user.id))

// In DetailViewModel
@HiltViewModel
class DetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val repository: UserRepository
) : ViewModel() {
    private val userId = savedStateHandle.get<String>("id")!!
    val user = repository.getUser(userId).stateIn(viewModelScope, ...)
}

Add deep link support per composable:

kotlin
composable(
    route = Screen.Detail("").route,
    arguments = listOf(navArgument("id") { type = NavType.StringType }),
    deepLinks = listOf(
        navDeepLink { uriPattern = "https://yourapp.com/detail/{id}" }
    )
) { backStackEntry ->
    DetailScreen(id = backStackEntry.arguments?.getString("id") ?: "")
}

Then declare the intent filter in your manifest. Deep links work out of the box with this setup.

After 10+ apps, this is the pattern I reach for every time. It's not perfect — Compose Navigation still has rough edges around dialog destinations and multi-module setups — but it handles the common cases cleanly.

Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this