Recipe: Tabs with independent back stacks¶
Problem: a bottom-navigation app where each tab keeps its own navigation history — switching tabs and coming back lands you where you left off, not at the tab’s root.
Make the tab host a Circuit UI that owns its own NavStack, Navigator, and
NavigableCircuitContent. Switch tabs with resetRoot(..., StateOptions.SaveAndRestore) so each
tab keeps its stack.
@Parcelize
data object HomeScreen : Screen
// One entry per tab. Each knows its root Screen and how to render in the bottom bar.
enum class HomeTab(val rootScreen: Screen, val label: String, val icon: ImageVector) {
Feed(FeedScreen, "Feed", Icons.Default.Home),
Search(SearchScreen, "Search", Icons.Default.Search),
Profile(ProfileScreen, "Profile", Icons.Default.Person);
companion object {
fun of(rootScreen: Screen): HomeTab = entries.first { it.rootScreen == rootScreen }
}
}
The UI owns the nested NavStack and Navigator, so tab switching can happen directly from the tab
click. The selected tab is derived from the nav stack’s current root:
@CircuitInject(HomeScreen::class, AppScope::class)
@Composable
fun Home(modifier: Modifier = Modifier) {
// This tab host owns its own navigable surface.
val navStack = rememberSaveableNavStack(HomeTab.entries.first().rootScreen)
val navigator = rememberCircuitNavigator(navStack) {
// Do something when the root screen is popped, usually exiting the app
}
// Highlight follows the stack root.
val currentTab by remember {
derivedStateOf { navStack.rootRecord?.screen?.let(HomeTab::of) ?: HomeTab.entries.first() }
}
Scaffold(
modifier = modifier,
bottomBar = {
NavigationBar {
HomeTab.entries.forEach { tab ->
NavigationBarItem(
selected = tab == currentTab,
onClick = { navigator.resetRoot(tab.rootScreen, StateOptions.SaveAndRestore) },
icon = { Icon(tab.icon, contentDescription = tab.label) },
label = { Text(tab.label) },
)
}
}
},
) { padding ->
NavigableCircuitContent(navigator, navStack, modifier = Modifier.padding(padding))
}
}
HomeScreen can be a StaticScreen because the UI owns the nested navigation
state. A presenter returning a trivial state works too; the tab logic still stays in the UI.
What this gives you:
- The nested
NavigatorandBavStacklive inside the tab host. SaveAndRestoresaves the outgoing tab’s stack and restores the target tab’s saved stack when it has one. Open Feed, drill intoDetailScreen, switch to Profile, switch back, andDetailScreenis still on top.- The first visit to a tab has nothing to restore, so it starts at that tab’s root screen.
You do not need to track per-tab stacks or a separate “current tab” field. The nav stack root is the
selected tab, and SaveAndRestore stores the tab stacks.
The full StateOptions API¶
resetRoot takes a Navigator.StateOptions,
a data class of three independent flags (all default false):
| Flag | Effect |
|---|---|
save |
Save the current entry list before resetting, keyed by the current root screen. It can be restored later by resetting back to that root with restore = true. |
restore |
If the new root has previously-saved state, restore that whole stack instead of starting fresh. If false or there’s nothing saved, the stack becomes just the new root. |
clear |
Discard any saved state for the new root. Applied after a restore, regardless of restore. |
Two presets cover the common cases:
| Preset | Equivalent | Use |
|---|---|---|
StateOptions.Default |
StateOptions() |
single back stack — save/restore nothing |
StateOptions.SaveAndRestore |
StateOptions(save = true, restore = true) |
multiple back stacks (this recipe) |
For anything else, construct one directly — e.g. save the outgoing stack but always start the new
tab fresh: StateOptions(save = true, restore = false).
See also: Navigation ·
StateOptions API ·
the bottom-navigation sample