Navigation 3 in Jetpack Compose: Back Stack as State

AndroidJetpack ComposeNavigationKotlin

For years, Android navigation meant handing your back stack to a controller and poking it through an imperative API — navigate("route"), popBackStack() — while the actual stack stayed hidden inside the framework. It worked, but it fought Compose's core idea that UI is a function of state you own. Navigation 3 flips this: the back stack is just a list you hold in your own state, and the library renders it. Navigate by adding to the list; go back by removing from it. That's the whole model, and it's a much better fit for how Compose actually thinks.

I've shipped apps on Navigation Compose and its predecessors, and the recurring pain was always that navigation state lived somewhere I couldn't easily read, test, or restore. Nav3 puts it back in my hands. Here's what changes and why it matters.

The back stack is ordinary state

In Nav3 you keep the back stack as an observable list of keys — plain data describing where the user is:

val backStack = rememberNavBackStack(HomeKey)

NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider {
        entry<HomeKey> { HomeScreen(onOpen = { backStack.add(DetailKey(it)) }) }
        entry<DetailKey> { key -> DetailScreen(id = key.id) }
    },
)

Look at what navigation is here: backStack.add(DetailKey(id)) to go forward, removeLastOrNull() to go back. No controller, no string routes to parse, no separate navigation graph DSL. The keys are your own typed classes, so a destination carries its arguments directly — DetailKey(id) instead of building and parsing "detail/$id". Type-safe by construction, and the compiler catches a missing argument instead of a runtime crash.

Why "state you own" is the whole point

Because the back stack is a normal list, everything Compose is good at applies to navigation for free:

This is the same philosophy as state hoisting in Compose, extended to navigation. Once navigation state is hoisted into ordinary observable state, it stops being a special case.

Deep links become "build a list"

Deep linking in controller-based navigation was always fiddly — you'd declare intent filters, map URIs to routes, and hope the synthesized back stack was right. In Nav3, a deep link is just a function that produces the initial list:

fun backStackFor(uri: Uri): List<NavKey> = when {
    uri.pathSegments.firstOrNull() == "product" ->
        listOf(HomeKey, CatalogKey, DetailKey(uri.lastPathSegment!!))
    else -> listOf(HomeKey)
}

You're literally constructing the stack you want the user to land on, with a sensible parent chain so "back" behaves. Because it's plain code producing plain data, it's trivial to unit test that a given URL yields the right stack — something that was genuinely painful before.

Multi-pane and adaptive navigation fall out naturally

Here's where the model really earns its keep. On a tablet or foldable you often want list and detail side by side. With a hidden back stack that's awkward; with a list you own, it's obvious — render the top N entries as panes instead of just the top one.

Nav3 provides scenes/strategies to do exactly this: a strategy can decide "if the window is expanded and the top two entries are list + detail, show them together." Combine it with the adaptive layout tools — window size classes — and the same back stack drives a single-pane phone experience and a two-pane tablet experience with no duplicate navigation logic. The back-stack-as-list model is what makes that clean; you're just choosing how many entries to display.

Migrating from Navigation Compose

A few honest notes if you're considering the move:

Aspect Navigation Compose Navigation 3
Back stack Hidden in NavController A list you own
Destinations String routes / typed routes Your own key data classes
Navigate navController.navigate(...) backStack.add(...)
Deep links URI-to-route mapping Function returning a list
Multi-pane Manual / awkward Render top-N entries

Migration is real work — the mental model and the API both change — so don't rip out a working Navigation Compose setup for its own sake. Where Nav3 pays off is greenfield apps, apps targeting foldables and large screens seriously, or apps where navigation state has become hard to test and restore. Nav3 also composes well with scoped ViewModels and lifecycle: each entry can own its own lifecycle and scoped state, so per-screen ViewModels still work as you'd expect.

The gotchas

The takeaway

Navigation 3's one big idea — the back stack is state you own — resolves the longstanding friction between Android navigation and Compose. Typed keys instead of string routes, a list you can test and restore instead of a hidden controller, deep links as list construction, and multi-pane layouts as "render more entries." It's more explicit, which means slightly more responsibility, but for large-screen and testability-focused apps it's the model I'd start new work on. Navigation finally works the way the rest of Compose does.

Resources

Frequently asked questions

What is Navigation 3 for Jetpack Compose?

Navigation 3 (Nav3) is a rethink of Android navigation where the back stack is an ordinary observable list you own in your own state, rather than being hidden inside a framework controller. You mutate the list to navigate, and the library renders the top entries as UI.

How is Navigation 3 different from Navigation Compose?

In Navigation Compose the NavController owns and hides the back stack behind an imperative API. In Navigation 3 the back stack is a plain list in your state that you read and mutate directly, which makes navigation state testable, serializable, and adaptive-friendly.

Does Navigation 3 support deep links and multi-pane layouts?

Yes. Because the back stack is just a list, a deep link becomes 'construct this list of destinations,' and multi-pane layouts become 'render the top N entries side by side.' Both are far more natural than in controller-based navigation.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →