Navigation in Compose (Navigation Compose and Navigation 3)

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Compose apps can reach for two different navigation libraries. Navigation Compose is the Compose front end for the same AndroidX Navigation library used by fragment-based navigation: a NavController drives a graph of composable destinations, and the library owns the back stack internally. Navigation 3, a from-scratch library first released stable in November 2025, inverts that relationship: the app itself owns the back stack as an ordinary, observable list, and the library’s only job is to render whatever that list currently contains. This page covers Navigation Compose 2.10.2 (androidx.navigation:navigation-compose) and Navigation 3 1.2.0 (androidx.navigation3:navigation3-runtime and androidx.navigation3:navigation3-ui), how to choose between them, and how TrailMate’s Compose UI — introduced on Jetpack Compose Fundamentals and built out on Compose Layouts, Modifiers and Lazy Lists and State, Recomposition and Side Effects in Compose — can be wired up either way. ViewModel creation and scoping (hiltViewModel(), viewModel(), ViewModel lifecycle) is introduced briefly here and covered in full on ViewModel, Lifecycle-Aware Components, Coroutines and Flow; Kotlin serialization (@Serializable), which both libraries use for type-safe routes, is a language feature and is not retaught here.

Both libraries assume the Gradle setup from Project Structure, Gradle and Build Variants plus the org.jetbrains.kotlin.plugin.serialization Gradle plugin, which generates the KSerializer each route class needs, and the org.jetbrains.kotlin.plugin.compose Compose compiler plugin every Compose module applies. On AGP 9 the org.jetbrains.kotlin.android plugin is no longer applied: Kotlin support is built into AGP itself (see Android Developers — Migrate to built-in Kotlin):

// app/build.gradle.kts
plugins {
    id("com.android.application") // AGP 9.4.1: Kotlin is built in, no org.jetbrains.kotlin.android plugin
    id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" // Compose compiler
    id("org.jetbrains.kotlin.plugin.serialization") version "2.4.20" // generates route serializers
}

dependencies {
    // Navigation Compose 2.10.2
    implementation("androidx.navigation:navigation-compose:2.10.2")

    // Navigation 3 1.2.0
    implementation("androidx.navigation3:navigation3-runtime:1.2.0")
    implementation("androidx.navigation3:navigation3-ui:1.2.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.11.0")
    implementation("androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0")

    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
}

Navigation Compose 2.10: Type-Safe Routes, NavHost and NavController

Since Navigation 2.8, routes are ordinary Kotlin classes and objects annotated @Serializable, not string paths: a screen with no arguments is a data object, a screen with arguments is a data class. TrailMate’s three top-level routes are declared once and reused by both the graph and every navigate() call:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailMateRoutes.kt
import kotlinx.serialization.Serializable

@Serializable
data object TrailList

@Serializable
data class TrailDetail(val trailId: String)

@Serializable
data object Settings

A NavHost associates a NavController with a graph built from these route types. Each destination is declared with composable<T> instead of a string-keyed composable("route"), so the compiler checks that every navigate() call and every argument read agrees with the route’s actual shape:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute

@Composable
fun TrailMateApp(navController: NavHostController = rememberNavController()) {
    NavHost(navController = navController, startDestination = TrailList) {
        composable<TrailList> {
            TrailListScreen(
                onTrailClick = { trailId -> navController.navigate(TrailDetail(trailId)) },
                onSettingsClick = { navController.navigate(Settings) },
            )
        }
        composable<TrailDetail> { backStackEntry ->
            val route: TrailDetail = backStackEntry.toRoute()
            TrailDetailScreen(trailId = route.trailId)
        }
        composable<Settings> {
            SettingsScreen(onBackClick = { navController.popBackStack() })
        }
    }
}

toRoute<T>() deserializes the current NavBackStackEntry’s arguments back into the route instance, which is why `TrailDetail(val trailId: String) needs no separate argument-parsing code — the same class is the route and the typed argument bundle. Following the rule of passing only lightweight identifiers between destinations, TrailDetail carries only trailId, never a whole Trail; TrailDetailViewModel re-fetches the full trail from TrailRepository using that ID (via SavedStateHandle, which Navigation Compose populates from the same route arguments — see ViewModel, Lifecycle-Aware Components, Coroutines and Flow). See Android Developers — Navigation, Android Developers — Type safety in Navigation Compose and Android Developers — Navigate with NavController.

Controlling the Back Stack: popUpTo and launchSingleTop

Every navigate() call pushes a new entry by default, which is wrong for two common cases: re-selecting a bottom-bar tab that is already showing (it would stack a duplicate copy of the same screen), and finishing a flow by returning to an earlier destination while discarding everything pushed since (a "sign in, then land back on whatever screen the user actually wanted" flow). launchSingleTop = true avoids the first case by reusing the existing top-of-stack entry instead of pushing a duplicate; popUpTo avoids the second by popping destinations off the stack, inclusive or not, before pushing the new one:

// A bottom-bar destination: never stack duplicates, and always return to a single instance
// of each tab instead of accumulating a deep stack of tab switches.
navController.navigate(TrailList) {
    launchSingleTop = true
    popUpTo(navController.graph.findStartDestination().id) {
        saveState = true
    }
    restoreState = true
}

// Finishing a "record hike" flow: drop RecordHike and everything pushed on top of it,
// landing back on TrailDetail.
navController.navigate(TrailDetail(trailId)) {
    popUpTo<RecordHike> { inclusive = true }
}

popUpTo<T> (a reified, type-safe overload) and the string/ID overloads behave the same way; saveState/ restoreState additionally save and restore each destination’s own UI state (scroll position, rememberSaveable values) across the pop, which is what makes repeated bottom-bar taps feel instantaneous instead of resetting each tab. See Android Developers — Back stack and multitasking with Navigation Compose.

Nested Graphs with navigation<T>

A nested graph groups a set of destinations that only make sense together — a multi-step flow, or a feature module’s own screens — behind a single route that the outer graph navigates to. navigation<T> takes the same @Serializable route pattern, plus a nested startDestination:

@Serializable data object HikeRecordingGraph
@Serializable data object RecordHike
@Serializable data class HikeSummary(val hikeId: String)

NavHost(navController = navController, startDestination = TrailList) {
    composable<TrailList> { /* ... */ }

    navigation<HikeRecordingGraph>(startDestination = RecordHike) {
        composable<RecordHike> { RecordHikeScreen(/* ... */) }
        composable<HikeSummary> { backStackEntry ->
            val route: HikeSummary = backStackEntry.toRoute()
            HikeSummaryScreen(hikeId = route.hikeId)
        }
    }
}

A ViewModel shared by every destination inside HikeRecordingGraph (recording state that must survive moving from RecordHike to HikeSummary) is obtained by scoping hiltViewModel() to the graph’s back stack entry rather than the current destination’s, using navController.getBackStackEntry(…​):

composable<RecordHike> { backStackEntry ->
    val graphEntry = remember(backStackEntry) {
        navController.getBackStackEntry<HikeRecordingGraph>()
    }
    val sharedViewModel: HikeRecordingViewModel = hiltViewModel(graphEntry)
    RecordHikeScreen(viewModel = sharedViewModel)
}

See Android Developers — Migrate to Navigation 3 (which contrasts this pattern with Navigation 3’s own scoping model, covered below) and Android Developers — Back stack and multitasking.

A type-safe deep link attaches a navDeepLink<T> (or the string/URI overload for legacy routes) to a composable<T> destination, mapping an incoming URI’s path segments directly onto the route’s constructor arguments:

composable<TrailDetail>(
    deepLinks = listOf(navDeepLink<TrailDetail>(basePath = "https://trailmate.example.com/trails")),
) { backStackEntry ->
    val route: TrailDetail = backStackEntry.toRoute()
    TrailDetailScreen(trailId = route.trailId)
}

An intent filter in the manifest routes matching https://trailmate.example.com/trails/{trailId} URIs to MainActivity, and the Navigation library resolves the rest — constructing the TrailDetail route, building a synthetic back stack up to it, and delivering it to the running NavHost. See Android Developers — Support deep links (Navigation 3) for the contrasting, app-owned model Navigation 3 uses instead, and App Components, the Manifest and Intents for the manifest side of deep links.

Navigation 3 1.2: An App-Owned Back Stack

Navigation 3 (androidx.navigation3, stable since 1.0.0 on 2025-11-19, current 1.2.0) is not a new version of the same library: it is a deliberately smaller runtime that renders whatever back stack the app hands it, and leaves back-stack manipulation, persistence and scoping to a small set of composable helpers the app wires up itself. Where Navigation Compose’s NavController is an opaque object the app calls methods on, Navigation 3’s back stack is just a List<NavKey> the app can inspect, mutate and reason about directly.

Routes as NavKey

Every Navigation 3 route implements the marker interface NavKey and is @Serializable, so it can be round-tripped through rememberSaveable and survive process death:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailMateNavKeys.kt
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

@Serializable
data object TrailList : NavKey

@Serializable
data class TrailDetail(val trailId: String) : NavKey

@Serializable
data object Settings : NavKey

rememberNavBackStack and NavDisplay

rememberNavBackStack(vararg elements: NavKey) creates and remembers the back stack itself — a NavBackStack<NavKey>, a SnapshotStateList under the hood — so it survives configuration changes and process death the same way rememberSaveable state does. NavDisplay observes that list and renders whatever it currently points at, resolving each NavKey to a composable through an entryProvider, most conveniently built with the entryProvider { } DSL and its entry<T> builder:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay

@Composable
fun TrailMateApp() {
    val backStack = rememberNavBackStack(TrailList)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider {
            entry<TrailList> {
                TrailListScreen(
                    onTrailClick = { trailId -> backStack.add(TrailDetail(trailId)) },
                    onSettingsClick = { backStack.add(Settings) },
                )
            }
            entry<TrailDetail> { key ->
                TrailDetailScreen(trailId = key.trailId)
            }
            entry<Settings> {
                SettingsScreen(onBackClick = { backStack.removeLastOrNull() })
            }
        },
    )
}

Navigating forward is backStack.add(TrailDetail(trailId)); navigating back is backStack.removeLastOrNull(). There is no NavController, no popUpTo DSL and no launchSingleTop flag — both are just list operations the app writes directly (backStack.removeAll { it is RecordHike } to drop a flow, if (backStack.lastOrNull() != TrailList) backStack.add(TrailList) to avoid a duplicate top entry), which is the trade-off Navigation 3 makes: less API surface to learn, more explicit code at each call site. See Android Developers — Navigation 3, Android Developers — Get started with Navigation 3, Android Developers — Understand the basics of Navigation 3 and Android Developers — Save and manage navigation state.

Entry Decorators: Saveable State and ViewModel Scoping

NavDisplay applies a list of entryDecorators to every entry before rendering it. The default, rememberSaveableStateHolderNavEntryDecorator(), gives each entry its own rememberSaveable scope so that state saved by an entry that later moves off-screen (but stays on the back stack) is restored correctly when it comes back. The androidx.lifecycle:lifecycle-viewmodel-navigation3 add-on contributes a second decorator, rememberViewModelStoreNavEntryDecorator(), which scopes a ViewModelStore to each back-stack entry — a ViewModel obtained inside an entry<T> block is created when that key is pushed and cleared when it is popped, mirroring what a NavController-owned back-stack entry does for hiltViewModel() in Navigation Compose. Passing a custom entryDecorators list replaces the default, so the saveable-state decorator must stay first in it.

Unlike Navigation Compose, Navigation 3 does not copy a NavKey’s fields into the entry’s `SavedStateHandle, so savedStateHandle.toRoute<TrailDetail>() finds no arguments there. The key’s data is passed to the ViewModel explicitly instead — with Hilt, through assisted injection and the hiltViewModel(creationCallback = …​) overload, as introduced on Dependency Injection with Hilt, Dagger and Koin:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay

// TrailDetailViewModel is declared with
// @HiltViewModel(assistedFactory = TrailDetailViewModel.Factory::class) and an
// @AssistedInject constructor taking @Assisted trailId: String.
NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator(),
    ),
    entryProvider = entryProvider {
        entry<TrailDetail> { key ->
            val viewModel = hiltViewModel<TrailDetailViewModel, TrailDetailViewModel.Factory>(
                creationCallback = { factory -> factory.create(key.trailId) },
            )
            TrailDetailScreen(viewModel = viewModel)
        }
    },
)

Sharing one ViewModel across several entries (the HikeRecordingGraph case from Navigation Compose above) is now a matter of scoping the ViewModel lookup to a shared key the app chooses, rather than to a graph route the library defines — for example, obtaining it once above the whole NavDisplay and passing it down, since there is no separate "nested graph" concept in Navigation 3’s flat back stack. See Android Developers — Apply logic to destinations with NavEntryDecorators and ViewModel, Lifecycle-Aware Components, Coroutines and Flow for ViewModel scoping in general.

Scenes: List-Detail and Other Multi-Pane Layouts

A Scene is the unit NavDisplay actually renders: normally one NavEntry fills the whole scene, but a SceneStrategy can combine several back-stack entries into one multi-pane Scene — the mechanism behind adaptive, large-screen layouts. androidx.compose.material3.adaptive:adaptive-navigation3 ships a ready-made list-detail strategy, rememberListDetailSceneStrategy<NavKey>(), plus the ListDetailSceneStrategy.listPane()/ detailPane()/extraPane() companion functions, which build the metadata that tags which entries belong to which pane:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay

@Composable
fun TrailMateApp() {
    val backStack = rememberNavBackStack(TrailList)
    val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<TrailList>(metadata = ListDetailSceneStrategy.listPane()) {
                TrailListScreen(
                    onTrailClick = { trailId ->
                        // Replace the shown detail instead of stacking one detail per click.
                        backStack.removeAll { it is TrailDetail }
                        backStack.add(TrailDetail(trailId))
                    },
                )
            }
            entry<TrailDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
                TrailDetailScreen(trailId = key.trailId)
            }
        },
    )
}

On a wide window, TrailListScreen and TrailDetailScreen render side by side in one ListDetailScene; on a narrow window, NavDisplay falls back to its default SinglePaneSceneStrategy and shows one entry at a time, with no change to the destinations themselves. See Adaptive Layouts, Edge-to-Edge and Large Screens for window size classes and canonical layouts in general, and Android Developers — Overview of scenes for writing a custom SceneStrategy.

The Results API

Since Navigation 3 1.2.0, a screen returns a result to whichever entry navigated to it through a ResultEventBus. The bus is supplied to every entry by adding rememberResultEventBusNavEntryDecorator() to NavDisplay’s `entryDecorators — without it, LocalResultEventBus.current has no provider and fails at runtime — and is read with LocalResultEventBus.current. This replaces the SavedStateHandle-based "previous back stack entry" result pattern Navigation Compose uses:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.runtime.result.LocalResultEventBus
import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator
import androidx.navigation3.ui.NavDisplay

NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberResultEventBusNavEntryDecorator(), // provides LocalResultEventBus
    ),
    entryProvider = entryProvider {
        // A photo-capture screen sends its result back to whoever pushed it.
        entry<PhotoCapture> {
            val resultBus = LocalResultEventBus.current
            PhotoCaptureScreen(
                onPhotoCaptured = { photoUri: Uri ->
                    resultBus.sendResult(resultKey = "captured_photo", result = photoUri)
                    backStack.removeLastOrNull()
                },
            )
        }

        // TrailDetailScreen observes the latest result as Compose state.
        entry<TrailDetail> { key ->
            val resultBus = LocalResultEventBus.current
            val capturedPhoto by resultBus.conflateAsState<Uri?>(
                resultKey = "captured_photo",
                defaultValue = null, // shown until a result arrives
            )
            TrailDetailScreen(trailId = key.trailId, capturedPhoto = capturedPhoto)
        }
    },
)

ResultEffect is the event-based counterpart for a one-shot result (a snackbar shown exactly once) instead of an observed, replayable value. Results delivered this way live only in memory and do not by themselves survive process death; an app that needs that guarantee still persists the value (a repository, SavedStateHandle) as well. See Android Developers — Return results.

NavDisplay participates in the system predictive-back gesture out of the box: as the user swipes back, it previews the destination underneath before committing the pop, the same way the Views-based predictive-back transitions do. No extra wiring is required beyond targeting an SDK where predictive back is enabled and letting NavDisplay own the onBack callback, as in the examples above; see Adaptive Layouts, Edge-to-Edge and Large Screens for the platform-wide predictive-back rules that apply to every screen, Compose or Views.

Deep links are, consistently with the rest of Navigation 3, something the app builds itself rather than something the library resolves for it. A DeepLinkMatcher (a UriDeepLinkMatcher for a URI pattern given as a DeepLinkUri, a StaticKeyDeepLinkMatcher for a fixed key, or a custom implementation) turns an incoming DeepLinkRequest into a match result carrying a NavKey, optionally wrapped by withBackStack { } — which then yields a BackStackMatchResult — to synthesize the intermediate entries the deep link implies (so opening a trail deep link still lands on a back stack of [TrailList, TrailDetail(id)], not just [TrailDetail(id)]):

// app/src/main/kotlin/com/example/trailmate/TrailMateDeepLinks.kt
import android.content.Intent
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.deeplink.BackStackMatchResult
import androidx.navigation3.runtime.deeplink.DeepLinkMatcher
import androidx.navigation3.runtime.deeplink.DeepLinkRequest
import androidx.navigation3.runtime.deeplink.DeepLinkUri
import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher
import androidx.navigation3.runtime.deeplink.withBackStack
import kotlinx.serialization.serializer

private val trailDetailMatcher = UriDeepLinkMatcher(
    uriPattern = DeepLinkUri("https://trailmate.example.com/trails/{trailId}"),
    serializer = serializer<TrailDetail>(),
).withBackStack { matchResult -> listOf(TrailList, matchResult.key) }

private val deepLinkMatchers: List<DeepLinkMatcher<*, *>> = listOf(trailDetailMatcher)

// Called from MainActivity.onCreate() (and onNewIntent()) to build the initial back stack.
fun initialBackStack(intent: Intent): List<NavKey> {
    val request = DeepLinkRequest(intent = intent)
    val matchResult = deepLinkMatchers.mapNotNull { it.match(request) }.maxOrNull()
    return when (matchResult) {
        null -> listOf(TrailList)
        is BackStackMatchResult<*, *> -> {
            @Suppress("UNCHECKED_CAST")
            matchResult.backStack as List<NavKey>
        }
        else -> listOf(matchResult.key as NavKey)
    }
}

MainActivity passes the result to rememberNavBackStack(*initialBackStack(intent).toTypedArray()), so a cold start from a deep link begins on [TrailList, TrailDetail(id)] while a normal launch begins on [TrailList].

Choosing Between Navigation Compose and Navigation 3

Concern Navigation Compose 2.10 Navigation 3 1.2

Back stack ownership

Owned by NavController, mutated through its API

Owned by the app, an ordinary observable List<NavKey>

Popping/single-top behavior

popUpTo/launchSingleTop DSL on navigate()

Plain list operations (removeLastOrNull, removeAll, add)

ViewModel scoped to a destination

hiltViewModel() scoped to the NavBackStackEntry

hiltViewModel() + rememberViewModelStoreNavEntryDecorator()

Sharing state across a flow

A nested graph’s shared NavBackStackEntry (navigation<T>)

App-defined scoping (no built-in nested-graph concept)

Adaptive, multi-pane layouts

Composed by hand (ListDetailPaneScaffold beside a NavHost)

Built in via SceneStrategy (rememberListDetailSceneStrategy)

Returning a result to the previous screen

SavedStateHandle on the previous back stack entry

ResultEventBus (sendResult/conflateAsState/ResultEffect)

Deep links

Declared per destination (navDeepLink<T>), resolved by the library

Built by the app (DeepLinkMatcher), assembled into a back stack

Maturity (as of this baseline)

Stable since 2011, type-safe routes since 2.8 (2024)

Stable since 1.0.0 (Nov 2025); 1.2.0 adds results, predictive back polish

Navigation Compose remains the default choice for most apps: it is stable, its patterns (popUpTo, launchSingleTop, nested graphs, hiltViewModel() scoping) are well understood, and most third-party libraries and samples still target it. Navigation 3 is worth reaching for when an app already needs the kind of control its explicit back stack gives — complex, conditional flows that are awkward to express as a static graph, or an adaptive, multi-pane UI where scenes are a better fit than composing several navigation hosts by hand — and is the direction Google is investing in going forward. A codebase does not have to choose exactly one: TrailMate’s legacy, Views-based screens keep using the classic Navigation component regardless of which of the two Compose options its Compose screens use.

TrailMate’s Bottom Bar with NavigationSuiteScaffold

TrailMate’s three top-level destinations (TrailList, a hikes list, Settings) are shown behind a bottom navigation bar on phones and a navigation rail on larger windows. NavigationSuiteScaffold picks the right container for the current window size automatically, and works the same way regardless of which navigation library renders the content passed to it:

// app/src/main/kotlin/com/example/trailmate/TrailMateApp.kt
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope

private val topLevelDestinations = listOf(TrailList, Hikes, Settings)

@Composable
fun TrailMateApp() {
    val backStack = rememberNavBackStack(TrailList)

    NavigationSuiteScaffold(
        navigationSuiteItems = {
            topLevelDestinations.forEach { destination ->
                item(
                    selected = backStack.lastOrNull() == destination,
                    onClick = {
                        if (backStack.lastOrNull() != destination) {
                            backStack.removeAll { it in topLevelDestinations }
                            backStack.add(destination)
                        }
                    },
                    icon = { /* ... */ },
                    label = { /* ... */ },
                )
            }
        },
    ) {
        NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = { /* ... */ })
    }
}

The equivalent with Navigation Compose replaces the click handler’s list manipulation with the launchSingleTop/ popUpTo call shown earlier and keys selected off navController.currentBackStackEntryAsState() instead of backStack.lastOrNull(). `ViewModel`s scoped per bottom-bar destination (so switching tabs does not reset each screen’s state) are exactly the "ViewModel scopes" case covered on ViewModel, Lifecycle-Aware Components, Coroutines and Flow; `NavigationSuiteScaffold’s adaptive layout mechanics are covered in more depth on Adaptive Layouts, Edge-to-Edge and Large Screens.

Testing Navigation

Navigation Compose’s NavController can be swapped out for a TestNavHostController in a Compose UI test, which lets a test assert on the current destination without running the real NavHost:

// feature/trails/src/androidTest/kotlin/com/example/trailmate/feature/trails/TrailListScreenTest.kt
@get:Rule val composeRule = createComposeRule()

@Test
fun clickingATrail_navigatesToDetail() {
    lateinit var navController: TestNavHostController
    composeRule.setContent {
        navController = TestNavHostController(LocalContext.current).apply {
            navigatorProvider.addNavigator(ComposeNavigator())
        }
        TrailMateNavHost(navController = navController)
    }

    composeRule.onNodeWithText("Rocky Ridge").performClick()

    val route: TrailDetail = navController.currentBackStackEntry!!.toRoute()
    assertThat(route.trailId).isEqualTo("trail-1")
}

Navigation 3 needs no equivalent fixture: because the back stack is a plain List<NavKey> the app owns, a test drives navigation by mutating that list directly (backStack.add(TrailDetail("trail-1"))) and asserts on backStack.last(), without any NavController-shaped test double. Compose UI testing in general — semantics trees, composeRule, matchers — is covered on Instrumented and UI Testing (Espresso, Compose Testing, UI Automator). See Android Developers — Test navigation for the full TestNavHostController API.

Migrating from Navigation 2 to Navigation 3

Because the two libraries model the back stack so differently, migrating a screen is closer to a rewrite of its navigation wiring than a version bump: NavController calls become list mutations, NavHost’s graph-builder DSL becomes an `entryProvider, SavedStateHandle-based results become ResultEventBus calls, and any code that relied on a NavBackStackEntry’s lifecycle or `hiltViewModel() scoping needs the NavDisplay entry-decorator equivalents shown above. The official guide walks through the mapping destination by destination and is the right starting point for a real migration rather than attempting it from this page’s summary alone; it also documents an incremental path (running both libraries side by side, one feature module at a time) rather than requiring an all-at-once cutover. See Android Developers — Migrate to Navigation 3.

Figure: Back-Stack Operations

The following figure summarizes how the same three operations — push, pop and "pop up to, then push" — change the back stack in both libraries, since the vocabulary differs (navigate()/popBackStack()/popUpTo versus add()/removeLastOrNull()/removeAll()) even though the resulting stack shapes are identical.

stateDiagram-v2 direction LR state "[TrailList]" as s1 state "[TrailList, TrailDetail]" as s2 state "[TrailList, TrailDetail, PhotoCapture]" as s3 state "[TrailList, Hikes]" as s4 s1 --> s2 : push TrailDetail\nNav2 navigate(TrailDetail(id))\nNav3 backStack.add(TrailDetail(id)) s2 --> s3 : push PhotoCapture\nlaunchSingleTop = false s3 --> s2 : pop\nNav2 popBackStack()\nNav3 backStack.removeLastOrNull() s2 --> s1 : pop s1 --> s4 : popUpTo(start, inclusive=true) then push Hikes\nNav2 navigate(Hikes) { popUpTo(...) }\nNav3 backStack.removeAll { it in topLevel }; backStack.add(Hikes)