How Navigation 3 Works Under the Hood
How Navigation 3 Works Under the Hood
Navigation 3 is a ground up redesign of Jetpack navigation for Compose. Unlike Navigation 2, which adapted the Fragment based navigation model to Compose through NavController and XML graph definitions, Navigation 3 is built entirely on Compose primitives. The back stack is a SnapshotStateList, entries are immutable data classes, and the rendering pipeline uses AnimatedContent for transitions. The result is a navigation library that feels native to Compose rather than layered on top of it.
In this article, you'll explore how NavBackStack integrates with the snapshot system to make navigation reactive, how NavEntry uses contentKey based state scoping to preserve UI state per destination, the NavEntryDecorator pattern that enables state persistence and lifecycle management, how rememberDecoratedNavEntries tracks entry lifecycle across animations, the NavDisplay rendering pipeline with scene strategies and AnimatedContent, and how predictive back gestures drive seekable transitions.
The fundamental problem: Navigation as Compose state
Navigation 2 carried baggage from the Fragment era. NavController managed internal state imperatively: you called navigate() and popBackStack(), and the controller figured out what to show. This worked, but it conflicted with Compose's declarative model where UI is a function of state. Developers had to bridge two mental models: Compose's "state drives UI" and Navigation 2's "call methods to change screens."
Navigation 3 solves this by making the back stack itself Compose state. There is no NavController. The back stack is a SnapshotStateList that triggers recomposition when mutated. Navigation becomes a list operation:
// Navigate
backStack.add(DetailScreen(itemId = "123"))
// Pop
backStack.removeLast()
// Replace
backStack[backStack.lastIndex] = EditScreen(itemId = "123")
This is the design principle that shapes every other decision in the library. Because the back stack is just a list in Compose state, the framework can observe it, serialize it, and react to changes automatically.
NavKey and NavBackStack: State you can serialize
Every element in the back stack must implement NavKey, a marker interface that ties into kotlinx.serialization for process death recovery:
public interface NavKey
Routes are typically @Serializable data classes or data objects that implement NavKey:
@Serializable
data object Home : NavKey
@Serializable
data class Detail(val id: String) : NavKey
The NavBackStack class wraps a SnapshotStateList and delegates both the MutableList interface and the StateObject interface to it:
@Serializable(with = NavBackStackSerializer::class)
public class NavBackStack<T : NavKey> public constructor(
internal val base: SnapshotStateList<T>
) : MutableList<T> by base, StateObject by base, RandomAccess by base {
public constructor(vararg elements: T) : this(base = mutableStateListOf(*elements))
}
Implementing StateObject means NavBackStack participates directly in Compose's snapshot system. Any mutation, whether add, remove, or set, is tracked by the snapshot system and triggers recomposition in any composable that reads the list. This is the same mechanism that makes mutableStateListOf reactive, because NavBackStack delegates to one.
To persist the back stack across configuration changes and process death, rememberNavBackStack uses rememberSerializable with a NavBackStackSerializer that handles polymorphic serialization of NavKey subtypes:
@Composable
public fun rememberNavBackStack(
configuration: SavedStateConfiguration,
vararg elements: NavKey,
): NavBackStack<NavKey> {
return rememberSerializable(
configuration = configuration,
serializer = NavBackStackSerializer(PolymorphicSerializer(NavKey::class)),
) {
NavBackStack(*elements)
}
}
This article continues for subscribers
Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.
Become a Sponsor