Go back

Navigation 3 in Jetpack Compose: The Back Stack Is Just State

Android

Learn how Navigation 3 makes the back stack explicit state, enabling typed destinations, state restoration, and adaptive layouts in Compose.

Most Compose navigation examples still feel like XML Navigation rewritten in Kotlin. You define destinations elsewhere, push opaque routes around, encode arguments into strings, and trust a controller to own the real state. Navigation 3 changes that model in a way that finally feels native to Compose. Google describes it as a Compose-first navigation library where you own the back stack, NavDisplay observes that state, and navigation is just adding and removing keys from a list. It has been stable since late 2025, and it is still evolving actively, with July 2026 releases in both the stable and alpha tracks.

If you have ever felt that Navigation Compose was slightly off, this is why. Compose wants explicit state and deterministic UI. Traditional navigation APIs want graphs, controllers, and internal machinery. Navigation 3 closes that gap by making the back stack part of your app state instead of something hidden behind it.

Why Navigation Compose Always Felt Slightly Wrong

Navigation 2 was built for a different era. It has improved a lot, including type-safe destinations in recent versions, but its center of gravity is still a controller-driven model with internal navigation state. Google’s own Navigation 3 launch post is unusually direct here: Nav2 can make it harder to maintain a single source of truth because it owns internal state, while Nav3 makes you supply the state yourself.

That difference matters more in Compose than it ever did in fragments. In a declarative UI, hidden state is friction. It makes previews weaker, testing more awkward, and adaptive layouts harder because the thing your UI most wants to read, the current back stack, is not just normal app state. Navigation 3’s main improvement list reflects exactly that: simpler Compose integration, full control over the back stack, and support for layouts that can read more than one destination at the same time.

The practical takeaway is simple. Stop thinking in terms of a graph that decides your UI. Start thinking in terms of UI that renders whatever is currently on a state-backed stack.

The Mental Model Shift

In Navigation 3, the back stack contains keys, not screens. Those keys are usually simple serializable types. NavDisplay reads the current stack, resolves each key into a NavEntry, and renders the appropriate scene. Push is add. Pop is removeLastOrNull. That is the model.

A minimal example looks like this:

@Serializable
sealed interface AppScreen : NavKey {
@Serializable
data object Inbox : AppScreen
@Serializable
data class Thread(val id: String) : AppScreen
@Serializable
data object Settings : AppScreen
}
@Composable
fun App() {
val backStack = rememberNavBackStack(AppScreen.Inbox)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = entryProvider {
entry<AppScreen.Inbox> {
InboxScreen(
openThread = { id -> backStack.add(AppScreen.Thread(id)) },
openSettings = { backStack.add(AppScreen.Settings) }
)
}
entry<AppScreen.Thread> { screen ->
ThreadScreen(
threadId = screen.id,
up = { backStack.removeLastOrNull() }
)
}
entry<AppScreen.Settings> {
SettingsScreen(
up = { backStack.removeLastOrNull() }
)
}
}
)
}

What I like here is not just the syntax. It is the ownership model. The stack is yours. You can inspect it, test it, derive UI from it, persist it, split it across top-level tabs, or expose it from a state holder without translating between “real state” and “navigation state.” That is the first time Jetpack navigation has felt fully aligned with Compose.

There is also a subtle architectural win. Once the back stack is data, navigation stops being a special subsystem and starts being normal state management. That makes it easier to reason about edge cases like restoring a selected detail pane, rebuilding a flow after process death, or deciding what the app bar should show for a given hierarchy.

Typed Destinations Instead of String Routes

Even before Nav3, Google was already pushing developers away from string-interpolated routes and toward serializable Kotlin types because type-safe destinations eliminate runtime crashes from typos and wrong argument types. Navigation 3 doubles down on that direction because keys are the entire model.

This is the biggest day-to-day quality-of-life improvement. With strings, you are constantly translating domain data into route syntax. With typed keys, the route is already data:

@Serializable
sealed interface FeedScreen : NavKey {
@Serializable
data object Feed : FeedScreen
@Serializable
data class Article(val articleId: Long, val highlightCommentId: Long? = null) : FeedScreen
@Serializable
data object Bookmarks : FeedScreen
}

Now your navigation calls become boring in the best possible way:

backStack.add(FeedScreen.Article(articleId = 42, highlightCommentId = 7))

And your screen gets a properly typed object back:

entry<FeedScreen.Article> { screen ->
ArticleScreen(
articleId = screen.articleId,
highlightCommentId = screen.highlightCommentId
)
}

If your app is still using raw strings in Navigation 2, that is the first migration to do. Google’s migration guidance explicitly treats strongly typed routes as a prerequisite for moving to Navigation 3. In other words, do not try to modernize navigation while keeping the weakest part of the old model. Remove the strings first.

Saving State Without Pretending It Is Magic

Owning the stack also means owning restoration, but this is more feature than burden. Navigation 3 gives you rememberNavBackStack, which persists across configuration changes and process death when your keys implement NavKey and are annotated with @Serializable.

That does not mean every piece of screen state is saved automatically. It means the navigation state is saveable. Your text field contents, scroll position, and loaded data still need the right home. In practice, I think about state in three buckets:

The first bucket is navigation state itself. Which screen is on top, which detail item is selected, which flow the user is in. rememberNavBackStack is excellent for this.

The second bucket is screen-local UI state. Things like tab selection, form values, or transient filters should still use rememberSaveable or another explicit save mechanism. Navigation should not be your accidental state container.

The third bucket is screen-scoped business state. This is where Nav3’s decorators matter. If you want a ViewModel to live as long as a specific NavEntry remains on the back stack, Google provides a lifecycle add-on plus rememberViewModelStoreNavEntryDecorator(). Combined with rememberSaveableStateHolderNavEntryDecorator(), you get entry-scoped ViewModel lifetime and proper saveable state handling tied to that back stack entry.

That usually looks like this:

NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
// entries...
}
)

This is a healthier contract than the old magic feeling of “navigation somehow remembers things.” Nav3 makes the lifetime rules visible. Once you can see them, you can design them.

Adaptive Layouts Become Much More Natural

This is where Navigation 3 stops being a nicer API and starts being a better architecture. The Scenes API lets NavDisplay calculate layouts that can render more than one destination at the same time. Google calls this out as a core improvement over the original Navigation API, and the docs show list-detail strategies that switch between single-pane and multi-pane behavior based on window size and metadata attached to entries.

That is exactly the kind of thing that always felt forced in controller-centric navigation. In Nav2, list-detail often means bending a graph into shape. In Nav3, it can simply mean: the back stack contains both the list context and the selected detail, and the current scene decides whether to show one pane or two.

A simplified version using the Material adaptive integration looks like this:

@Serializable
data object MailList : NavKey
@Serializable
data class MailDetail(val id: String) : NavKey
@Composable
fun MailApp() {
val backStack = rememberNavBackStack(MailList)
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
sceneStrategies = listOf(listDetailStrategy),
entryProvider = entryProvider {
entry<MailList>(
metadata = ListDetailSceneStrategy.listPane()
) {
MailListScreen(
openMail = { id ->
backStack.removeIf { it is MailDetail }
backStack.add(MailDetail(id))
}
)
}
entry<MailDetail>(
metadata = ListDetailSceneStrategy.detailPane()
) { screen ->
MailDetailScreen(mailId = screen.id)
}
}
)
}

The important part is conceptual. On compact screens, the top of the stack behaves like standard forward navigation. On wider screens, the same stack can be rendered as list plus detail. No parallel navigation model. No special tablet graph. Just state and a different scene strategy.

If you already care about adaptive Compose layouts, this alone is a strong reason to pay attention to Nav3.

A Migration Path That Won’t Blow Up Your App

The official migration guide assumes an atomic migration for the flow you are converting, requires Compose destinations, and expects a compileSdk of 36 or later. It also explicitly recommends strongly typed routes before moving over. That is a useful constraint, not a limitation. It tells you where the fault lines are.

My advice is to resist the fantasy of a full-app rewrite. Instead, choose one bounded area where Nav3’s model gives immediate value. A settings flow is fine. A list-detail feature is better. A top-level tab with its own back stack is better still. The point is to migrate a slice where you can replace NavController with an explicit navigation state holder, move destinations from NavHost into an entryProvider, and let the rest of the architecture adapt around that change. Those are also the core steps Google lays out in the migration guide and launch post.

If I were doing this in a production app, my sequence would be straightforward. First, remove raw string routes. Second, define a sealed route hierarchy that matches your domain. Third, create a state holder that owns one or more back stacks. Fourth, replace the old graph with NavDisplay. Only after that would I start leaning into the more powerful parts like scene strategies, result passing, or custom decorators. Because once the back stack is just state, most of the hard part is already done.

Navigation 3 is not interesting because it is new. It is interesting because it finally treats navigation like the rest of a Compose app. The back stack is not a special object living outside your architecture anymore. It is state. Once you accept that, a lot of awkward navigation code starts to look unnecessary.