Bottom Navigation with Multiple Back Stacks in Jetpack Compose

Quick answer: Navigate between bottom-navigation destinations with launchSingleTop = true, restoreState = true, and popUpTo(findStartDestination()) { saveState = true }. The outgoing tab’s stack is saved and its prior stack is restored when the user returns.

Users expect a bottom-navigation tab to keep its place. If they open an article from Home, browse another tab, then return, they should see that article—not Home’s root screen. Navigation supports this pattern by saving destinations removed by popUpTo() and restoring them on a later navigation action. The official multiple-back-stacks guide documents the three options used below.

Model each tab as a top-level graph

Use one NavController and give each tab a top-level graph route. The graph can contain its own detail destinations; this keeps the navigation bar concerned with areas of the app, rather than every individual screen.

private const val HOME_GRAPH = "home"
private const val SEARCH_GRAPH = "search"
private const val LIBRARY_GRAPH = "library"

private val topLevelDestinations = listOf(
    HOME_GRAPH,
    SEARCH_GRAPH,
    LIBRARY_GRAPH,
)

NavHost(
    navController = navController,
    startDestination = HOME_GRAPH,
) {
    navigation(
        route = HOME_GRAPH,
        startDestination = "home/feed",
    ) {
        composable("home/feed") { HomeFeedScreen() }
        composable("home/article/{articleId}") { ArticleScreen() }
    }

    navigation(
        route = SEARCH_GRAPH,
        startDestination = "search/results",
    ) {
        composable("search/results") { SearchScreen() }
        composable("search/filter") { FilterScreen() }
    }

    navigation(
        route = LIBRARY_GRAPH,
        startDestination = "library/items",
    ) {
        composable("library/items") { LibraryScreen() }
    }
}

This is an illustrative graph. In a new app, the route strings can be replaced with the type-safe route classes described in Type-Safe Navigation Compose with Kotlin Serialization. The state-saving navigation options work with either style.

Save the tab you leave and restore the tab you select

Place the NavigationBar in the same app shell as the NavHost. currentBackStackEntryAsState() makes the selected tab react to navigation into a child destination, while the hierarchy check keeps the parent tab selected for that child.

import androidx.compose.runtime.getValue
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState

val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination

NavigationBar {
    topLevelDestinations.forEach { route ->
        NavigationBarItem(
            selected = currentDestination?.hierarchy?.any {
                it.route == route
            } == true,
            onClick = {
                navController.navigate(route) {
                    // Do not add another copy when the user reselects this tab.
                    launchSingleTop = true

                    // Recover this tab's previously saved child destinations.
                    restoreState = true

                    // Save the stack being left while returning to the root graph.
                    popUpTo(navController.graph.findStartDestination().id) {
                        saveState = true
                    }
                }
            },
            icon = { /* destination icon */ },
            label = { Text(route) },
        )
    }
}

The three options have different jobs:

OptionWhy it matters
popUpTo(...){ saveState = true }Removes the outgoing branch from the active stack while retaining its navigation and destination state.
restoreState = trueBrings back a saved branch when navigating to its matching top-level destination.
launchSingleTop = trueAvoids adding duplicate copies when the currently selected tab is tapped again.

restoreState has nothing to restore the first time a user opens a tab, which is normal. On later switches, the matching saved stack is restored. Keep the three options together in one tab-selection helper so a future destination does not accidentally lose its history.

Put the app shell together

The NavHost needs the padding supplied by Scaffold; otherwise its content can render behind the navigation bar. This outline shows the ownership boundary:

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

    Scaffold(
        bottomBar = {
            AppNavigationBar(navController = navController)
        },
    ) { innerPadding ->
        AppNavHost(
            navController = navController,
            modifier = Modifier.padding(innerPadding),
        )
    }
}

Keep NavController at this level and pass event callbacks into leaf screens. Navigation Compose: Create Your First NavHost explains that separation and the basic graph setup. For window-inset details, see Scaffold and Window Insets in Jetpack Compose.

Test the behavior users notice

Multiple back stacks are navigation state, not a substitute for durable app data. A restored screen should reload or observe business data from its normal source of truth. Test the UI state that belongs to a destination separately from the route history.

  • Open home/article/42, switch to Search, then return to Home: the article should still be on top.
  • Change tabs repeatedly and reselect the active tab: the back stack should not collect duplicates.
  • Press Back from a child destination: it should stay within that tab before exiting the app or moving through the expected root behavior.
  • Rotate the device and use developer-option process death: verify both the active route and screen state have the restoration behavior your product needs.
  • Test deep links into a tab’s child destination; Navigation builds the necessary nested graph hierarchy. Deep Links with Navigation Compose covers the Android intent side of that setup.

Common mistakes

Plain navigate() creates a new path or discards the old one depending on how the graph is manipulated. It does not express the product requirement to preserve each tab’s path. Use the save/restore pair deliberately.

Giving each tab its own NavController

Separate controllers can be appropriate for genuinely independent panes, but they add lifecycle, deep-link, and Back-dispatch complexity. A single controller with nested top-level graphs is the simpler default for one-pane bottom navigation.

Selecting a tab by exact destination only

If Home owns home/article/{articleId}, an exact comparison to HOME_GRAPH stops highlighting Home on the detail screen. Check the current destination’s hierarchy so child destinations still select their parent tab.

Treating a reselection as a normal navigation event

Products often want a selected-tab tap to scroll to top, refresh, or pop to that tab’s root. That is a product decision; handle it explicitly instead of relying on repeated navigate() calls to imply it.

FAQ

Does this keep my composable state too?

Navigation saves and restores eligible destination state as part of its saved back stack. Still use rememberSaveable for UI state that must survive recreation and a ViewModel or repository for business data. See rememberSaveable and Custom Savers in Jetpack Compose for choosing the right state owner.

Should I use this for a navigation rail too?

Yes. The back-stack strategy is about switching top-level destinations, not the visual control. A NavigationRail can use the same navigate() options; only the UI component changes.

Is Navigation 3 the same API?

No. This article uses the established navigation-compose NavController and NavHost APIs. Navigation 3 exposes explicit back-stack state and has its own multiple-back-stack recipe; evaluate it separately rather than mixing its APIs into this pattern.