Navigation and multiple screens

Navigation Compose routes and typed arguments, nested graphs, bottom navigation, deep links, and the back stack rules that avoid surprises.

Routes and arguments

@Serializable data class BookDetail(val bookId: Long)

@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = Home) {
        composable<Home> {
            HomeScreen(onOpenBook = { id -> navController.navigate(BookDetail(id)) })
        }
        composable<BookDetail> { entry ->
            val route: BookDetail = entry.toRoute()
            BookDetailScreen(bookId = route.bookId, onBack = navController::popBackStack)
        }
        composable<Settings> { SettingsScreen() }
    }
}

// pop up to a destination without leaving a trail
navController.navigate(Home) {
    popUpTo(navController.graph.startDestinationId) { saveState = true }
    launchSingleTop = true
    restoreState = true
}
  • Type-safe routes from a serializable class remove string concatenation and give compile-time checking of arguments.
  • Arguments arrive as part of the route; for anything larger, pass an identifier and load the entity in the ViewModel - do not serialise a whole object into the route.
  • launchSingleTop = true stops a second tap creating a duplicate destination, and restoreState with saveState keeps scroll position when switching bottom-navigation tabs.
  • System back and the up button are different: the back stack is the user's history, while an up action navigates to a logical parent. Configure both deliberately.
💡
A ViewModel obtained with hiltViewModel() inside a NavBackStackEntry is scoped to that destination, and is cleared when the entry is removed from the back stack. That is what makes a per-screen ViewModel behave as expected - and why hoisting it to the Activity breaks it.

Nested graphs and bottom navigation

@Serializable data object HomeGraph
@Serializable data object Home
@Serializable data object Profile
@Serializable data object Search

@Composable
fun RootNavHost() {
    val navController = rememberNavController()

    Scaffold(
        bottomBar = {
            NavigationBar {
                val entry by navController.currentBackStackEntryAsState()
                val current = entry?.destination
                NavigationBarItem(
                    selected = current?.hasRoute<Home>() == true,
                    onClick = { navController.navigateTopLevel(Home) },
                    icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
                    label = { Text("Home") },
                )
            }
        },
    ) { padding ->
        NavHost(navController, startDestination = HomeGraph, modifier = Modifier.padding(padding)) {
            navigation<HomeGraph>(startDestination = Home) {
                composable<Home> { HomeScreen() }
                composable<Search> { SearchScreen() }
            }
            navigation<ProfileGraph>(startDestination = Profile) {
                composable<Profile> { ProfileScreen() }
                composable<Settings> { SettingsScreen() }
            }
        }
    }
}

fun NavHostController.navigateTopLevel(route: Any) = navigate(route) {
    popUpTo(graph.findStartDestination().id) { saveState = true }
    launchSingleTop = true
    restoreState = true
}
RuleWhy
One NavHost per Activity in a Compose-only appTwo hosts means two back stacks and confusing behaviour
Nested graph per top-level sectionBottom-navigation tabs keep their own history
popUpTo the start destination for tabsOtherwise the stack grows with every tab switch
An argument only for identifiersRoutes are part of the back stack and of process-death restoration
popBackStack rather than navigate when going upAvoids duplicating a destination already on the stack
// in AndroidManifest.xml, inside the Activity
// <intent-filter>
//   <action android:name="android.intent.action.VIEW" />
//   <category android:name="android.intent.category.DEFAULT" />
//   <category android:name="android.intent.category.BROWSABLE" />
//   <data android:scheme="https" android:host="example.com" android:pathPrefix="/book" />
// </intent-filter>

composable<BookDetail>(
    deepLinks = listOf(
        navDeepLink<BookDetail>(basePath = "https://example.com/book"),
    ),
) { entry ->
    val route: BookDetail = entry.toRoute()
    BookDetailScreen(bookId = route.bookId)
}

// test navigation without a device or an emulator
@Test
fun navigatesToDetail() {
    val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
    composeTestRule.setContent { AppNavHost(navController = navController) }

    composeTestRule.onNodeWithText("The Dispossessed").performClick()

    assertEquals("BookDetail", navController.currentBackStackEntry?.destination?.route)
}
  • A deep link must produce the same state as navigating in the app. If the detail screen needs data the route does not carry, it must load it itself.
  • Handle the case where the link is opened for content the user cannot see: check authorization before rendering, rather than crashing on a missing entity.
  • TestNavHostController makes navigation testable in a JVM test, which is far faster than an instrumented test.
  • Set android:launchMode deliberately and handle onNewIntent, or a second deep link will create a second Activity instead of reusing the existing task.

FAQ

How do I pass a complex object between screens?
Do not. Pass an id and let the destination load the object, or share it through a repository and a scoped ViewModel. A serialised object in the route bloats the back stack and breaks when the object changes.
Why does my bottom navigation reset the scroll position?
You are calling navigate without saveState and restoreState, so the destination is recreated. Add both, and use launchSingleTop so repeated taps do not push duplicates.

Architecture: ViewModel, lifecycle and saved state Dependency injection with Hilt

Last refreshed 2026-09-18.