Lifecycle, ViewModel and Navigation in Compose Multiplatform

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

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

A screen in a shared Compose Multiplatform (CMP) app needs three things that Android developers get from the framework: a lifecycle that says when it is visible and active, a ViewModel that survives configuration changes and owns its state, and a navigation layer that moves between screens, handles the back button and accepts links from the outside. In shared code none of these may depend on an Activity. CMP therefore ships multiplatform builds of the Jetpack Lifecycle, ViewModel and Navigation libraries and maps the Android concepts onto UIKit, Swing and the browser. This page explains the mappings, the places where multiplatform differs from Android (no reflection on Kotlin/Native, no browser history in Navigation 3 yet, no Activity for deep links), and how TrailMate uses them.

The baseline is the one used across this section: Kotlin 2.4.20, Compose Multiplatform 1.12.1, AGP 9.4, Xcode 26.4, Ktor 3.6.0, Koin 4.2.2, SKIE 0.10.15, KMP-NativeCoroutines 1.0.6, SQLDelight 2.4.0, Room 3 (androidx.room3) 3.0.3, Mokkery 3.5.0, vanniktech 0.37.0, Dokka 2.2.0 and Turbine 1.2.1. Swift export and SwiftPM import are Alpha; Kotlin/Wasm and Compose Multiplatform for web are Beta. Library versions in the Gradle snippets below are the ones the Kotlin Multiplatform documentation lists for each artifact; check the current release before copying them. The examples are slices of TrailMate Everywhere (package com.example.trailmate, modules sharedLogic, sharedUI, androidApp, iosApp, desktopApp, webApp). The Android-only treatment of the same libraries is on Navigation in Jetpack Compose and ViewModel, Lifecycle and Coroutines; this page does not repeat them and concentrates on what changes once the code is shared. The Kotlin language itself is covered in the Kotlin section.

How the Pieces Fit Together

Compose Multiplatform does not reimplement these libraries; JetBrains builds the AndroidX sources for the non-Android targets and publishes them under the org.jetbrains.androidx.* group together with a reference to the original Android artifact. The Compose Multiplatform Gradle plugin resolves the right variant per target, so common code declares one dependency and Android still uses Google’s binary.

Concern Common-code API Android behaviour Non-Android behaviour

Lifecycle

LifecycleOwner from LocalLifecycleOwner, LifecycleEventEffect, LifecycleResumeEffect

The Activity or Fragment lifecycle

One shared owner per Compose host, driven by UIKit, Swing or browser events

ViewModel

viewModel \{ …​ }, koinViewModel()

ViewModelStore held by the Activity

A common ViewModelStoreOwner provided by the Compose host

Navigation

NavHost, NavController, or Navigation 3 NavDisplay with a NavKey back stack

Back stack saved in the instance state Bundle

Saved through the common saved-state registry; browser history only with an opt-in call

Back events

NavigationEventHandler

System back and predictive back

iOS swipe-from-edge gesture, desktop and web handled by the host

Lifecycle in Common Code

On Android a composable reads the lifecycle of its host Activity. A shared composable cannot do that, so Compose Multiplatform provides a common LifecycleOwner. By default every composable created by one Compose host (an Activity, a ComposeUIViewController, a Window, a ComposeViewport) shares the same owner, exposed through the LocalLifecycleOwner composition local. The states and events are the Jetpack ones: INITIALIZED, CREATED, STARTED, RESUMED and DESTROYED, with the events ON_CREATE, ON_START, ON_RESUME, ON_PAUSE, ON_STOP and ON_DESTROY. You can still create a separate owner for a subtree when it genuinely needs its own lifecycle, which is how NavHost gives each back stack entry its own.

// sharedLogic/build.gradle.kts (excerpt)
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.11.0")
        }
        // Dispatchers.Main is missing on desktop JVM by default
        // (needed by Lifecycle.coroutineScope and viewModelScope).
        jvmMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2")
        }
    }
}

Mapping Platform Events to Lifecycle Events

The platform events are translated as follows (this is the documented mapping; the exact callbacks can be refined in later releases).

Lifecycle event iOS Desktop (Swing) Web (Wasm)

ON_START

viewWillAppear, willEnterForeground

windowDeiconified

visibilitychange (becomes visible)

ON_RESUME

didBecomeActive

windowGainedFocus

focus

ON_PAUSE

willResignActive

windowLostFocus

blur

ON_STOP

viewDidDisappear, didEnterBackground

windowIconified

visibilitychange (stops being visible)

ON_DESTROY

viewControllerDidLeaveWindowHierarchy

dispose

Never reached

Three consequences follow. On iOS the app is RESUMED only while it is the active app, so a phone call or the notification shade pauses it; this is the signal you use to stop location updates. On desktop the lifecycle follows the window, so minimising pauses and stops it, while merely clicking another window only pauses it. On the web the CREATED state is skipped and DESTROYED never arrives, because the page is always attached and is simply discarded when the tab closes; do not rely on ON_DESTROY for cleanup in webApp, use DisposableEffect instead.

Reacting to the Lifecycle

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrackingEffect.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.example.trailmate.location.LocationProvider

@Composable
fun TrackingEffect(locationProvider: LocationProvider) {
    // Runs once per ON_START, whatever the platform
    LifecycleEventEffect(Lifecycle.Event.ON_START) {
        locationProvider.refreshPermissionState()
    }

    // Start when RESUMED, stop when PAUSED (phone call, window blur, app switch)
    LifecycleResumeEffect(locationProvider) {
        locationProvider.startUpdates()
        onPauseOrDispose { locationProvider.stopUpdates() }
    }
}

LifecycleEventEffect runs a block for one event; LifecycleResumeEffect pairs a start action with a stop action, in the same shape as DisposableEffect but tied to RESUMED. Both work in commonMain because they only depend on LocalLifecycleOwner. The effects themselves are explained on Compose State and Side Effects.

ViewModel in Common Code

The ViewModel class and viewModelScope are the same as on Android: state that outlives the composition and cancels its coroutines when its owner is gone. Compose Multiplatform supplies a common ViewModelStoreOwner per host, so a ViewModel created from a composable is retained as long as the host lives. The state-holder design, StateFlow exposure and unit testing of ViewModels are covered on Architecture and Shared ViewModels; here the focus is on creating and scoping them from Compose.

# gradle/libs.versions.toml (excerpt)
[versions]
androidx-viewmodel = "2.10.0"
koin = "4.2.2"

[libraries]
androidx-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-viewmodel" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }

Why viewModel() Needs an Initializer

On Android, viewModel<T>() can build T by reflection when it has a no-argument constructor. Kotlin/Native has no JVM-style runtime reflection, so common code cannot call viewModel() without parameters: you must provide an initializer (or a factory) every time. If you pass only an initializer, Compose Multiplatform creates a default factory around it.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrailListRoute.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.trailmate.di.appContainer
import com.example.trailmate.presentation.TrailListViewModel

@Composable
fun TrailListRoute(onTrailClick: (String) -> Unit) {
    // Initializer lambda: no reflection required, works on iOS, desktop and web
    val viewModel: TrailListViewModel = viewModel { TrailListViewModel(appContainer.trailRepository) }
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    TrailListScreen(uiState = uiState, onTrailClick = onTrailClick)
}

collectAsStateWithLifecycle() is the multiplatform counterpart of the Android function of the same name: it stops collecting while the common lifecycle is below STARTED, so a backgrounded iOS app does not keep a network refresh rendering into nothing. For a ViewModel with no dependencies, viewModel \{ OrderViewModel() } is enough; when it needs a SavedStateHandle or CreationExtras, use the viewModel(factory = …​) overload with a viewModelFactory built from initializer \{ …​ } blocks.

Constructor Injection with koinViewModel()

Hand-written initializers duplicate the wiring that a DI container already owns. With Koin, add koin-compose-viewmodel and ask the container for the ViewModel; the dependencies come from trailMateModule. How the module is declared and started on every platform is on Dependency Injection in KMP.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/TrailDetailRoute.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.trailmate.presentation.TrailDetailViewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf

@Composable
fun TrailDetailRoute(trailId: String, onBack: () -> Unit) {
    // The id is passed to the viewModel { params -> ... } definition in trailMateModule
    val viewModel: TrailDetailViewModel = koinViewModel(parameters = { parametersOf(trailId) })
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    TrailDetailScreen(uiState = uiState, onBack = onBack)
}

Metro offers the same shape with metroViewModel() from metrox-viewmodel-compose. Both integrations create the instance through the DI graph and register it in the same common ViewModelStore, so the lifecycle behaviour below is identical.

Scoping a ViewModel to a Screen

A ViewModel is scoped to the nearest ViewModelStoreOwner. Under Navigation Compose, every back stack entry is one, so viewModel \{ …​ } inside a composable<…​> destination is scoped to that destination and cleared when it is popped. Under Navigation 3 it is different: ViewModels are not scoped to entries by default, and without the decorators shown later they belong to the host (the Activity on Android), staying alive after the user has left the screen. This is the most common source of "my ViewModel never resets" reports after moving to Navigation 3.

Where viewModel \{ …​ } is called Scope Cleared when

Directly in App(), outside any navigation

The Compose host

The host is destroyed (Activity finished, window closed, tab closed)

Inside a Navigation Compose destination

That back stack entry

The entry is popped (or the nav graph is, with navGraphViewModels-style parent entries)

Inside a Navigation 3 entry with rememberViewModelStoreNavEntryDecorator()

That NavKey entry

The key is removed from the back stack

Inside a Navigation 3 entry without the decorator

The Compose host

The host is destroyed

Sharing Levels: Compose ViewModel Versus SwiftUI

The setup above is for the shared UI path. When TrailMate keeps SwiftUI and shares only the ViewModel, switch the dependency from the Compose artifact to plain lifecycle-viewmodel, export it with api(…​) so it appears in the framework, and let Swift own the lifecycle with SKIE, KMP-NativeCoroutines or KMP-ObservableViewModel. The Android side keeps using viewModel() as usual. Stability of the Swift-facing tools is described on Coroutines and Flow from Swift; the trade-offs of the whole spectrum are on iOS Integration Options.

// sharedLogic/build.gradle.kts (excerpt) -- native UI path (SwiftUI keeps the screens)
kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "SharedLogic"
            export(libs.androidx.lifecycle.viewmodel)   // ViewModel must be visible to Swift
        }
    }
    sourceSets {
        commonMain.dependencies {
            api(libs.androidx.lifecycle.viewmodel)       // not the -compose artifact
        }
    }
}

Navigation Compose: Type-Safe Routes

Compose Multiplatform adopts the Jetpack Compose navigation model: a NavController owns a back stack of entries, a NavHost renders the top entry, and a destination graph maps routes to composables. The multiplatform artifact is org.jetbrains.androidx.navigation:navigation-compose and supports Android, iOS, desktop and web. The concepts (NavHost, popUpTo, launchSingleTop, nested graphs) are the same as on Navigation in Jetpack Compose and are not repeated; what matters here is that routes are @Serializable types, because string routes with navArgument are the legacy approach and type-safe routes work without reflection on every target.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/Routes.kt
package com.example.trailmate.navigation

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable @SerialName("trails")
data object TrailList

@Serializable @SerialName("trail")
data class TrailDetail(val id: String)

@Serializable @SerialName("settings")
data object Settings

The kotlinx.serialization plugin (org.jetbrains.kotlin.plugin.serialization) must be applied to the module that declares the routes. @SerialName is optional but valuable: it fixes the name that appears in saved state, deep link base paths and, on the web, in the URL fragment, so renaming a class does not silently break stored back stacks or shared links.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/TrailMateNavHost.kt
package com.example.trailmate.navigation

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.trailmate.ui.SettingsRoute
import com.example.trailmate.ui.TrailDetailRoute
import com.example.trailmate.ui.TrailListRoute

@Composable
fun TrailMateNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
) {
    NavHost(navController = navController, startDestination = TrailList, modifier = modifier) {
        composable<TrailList> {
            TrailListRoute(
                onTrailClick = { id -> navController.navigate(TrailDetail(id)) },
                onSettings = { navController.navigate(Settings) },
            )
        }
        composable<TrailDetail> { entry ->
            val route: TrailDetail = entry.toRoute()
            TrailDetailRoute(trailId = route.id, onBack = { navController.popBackStack() })
        }
        composable<Settings> { SettingsRoute(onBack = { navController.popBackStack() }) }
    }
}

Each back stack entry implements LifecycleOwner. When a screen is covered by another, it moves from RESUMED to STARTED, and back when the user returns; "settled" (navigation is finished and the screen is active) is the RESUMED state. This is why LifecycleResumeEffect inside a destination stops location updates as soon as TrailDetail is pushed on top.

A deep link lets the operating system open TrailMate on a specific trail, for example trailmate://trail/42. Compose Multiplatform handles the navigation part in common code; registering the scheme and delivering the URI is platform work. Three steps are needed.

Step 1: Register the Scheme with Each Platform

Platform Where the scheme is declared Delivery to your code

Android

An intent filter in androidApp/src/main/AndroidManifest.xml

The Intent data in MainActivity.onCreate and onNewIntent

iOS

CFBundleURLTypes in iosApp/iosApp/Info.plist (or the Xcode URL Types UI)

SwiftUI onOpenURL or application(_:open:options:)

Desktop macOS

The Compose Gradle DSL writes values into the app’s Info.plist

Desktop.getDesktop().setOpenURIHandler

Desktop Windows and Linux

The installer (registry, package manifest, or a .desktop file); not configurable from the Compose project

Command-line argument passed to main

Web

Not applicable: the URL is the app address; parameters go in the fragment after #

Read by the browser navigation binding

<!-- iosApp/iosApp/Info.plist (excerpt) -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>trailmate</string>
        </array>
    </dict>
</array>

Each composable<T> takes a deepLinks list of NavDeepLink objects. You can write a general URI pattern, or let the library generate one from the route type with navDeepLink<T>(basePath = …​). In the generated form, required parameters become path segments, parameters with defaults become query parameters, and collections become repeated query parameters, all in the field order of the class.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/TrailMateNavHost.kt (excerpt)
composable<TrailDetail>(
    deepLinks = listOf(
        // Generated: trailmate://trail/{id}
        navDeepLink<TrailDetail>(basePath = "trailmate://trail"),
        // Hand-written, for an https link (an App Link on Android, a universal link on iOS)
        navDeepLink { uriPattern = "https://trailmate.example.com/trails/{id}" },
    ),
) { entry ->
    val route: TrailDetail = entry.toRoute()
    TrailDetailRoute(trailId = route.id, onBack = { navController.popBackStack() })
}

Overlapping patterns are a bug: if several destinations match the same URI the behaviour is indeterminate. A URI with no scheme is assumed to be http:// or https://. Always treat the received URI as untrusted input and validate the trail id before it reaches the repository.

Step 3: Handle the URI the App Receives

The common NavHost has no Intent or URL to read, so the usual pattern is a small singleton in common code that platform code feeds and the NavHost observes.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/DeepLinkBus.kt
package com.example.trailmate.navigation

import androidx.navigation.NavController
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow

object DeepLinkBus {
    // replay = 1 keeps a link received before the UI was composed (cold start)
    private val _links = MutableSharedFlow<String>(replay = 1, extraBufferCapacity = 1)
    val links: SharedFlow<String> = _links

    fun onUri(uri: String) { _links.tryEmit(uri) }
}

// Used from App(): LaunchedEffect(navController) { DeepLinkBus.links.collect { navController.navigate(...) } }
// iosApp/iosApp/iOSApp.swift
import SwiftUI
import SharedUI

@main
struct iOSApp: App {
    var body: some Scene {
        WindowGroup {
            ComposeHost()
                .ignoresSafeArea()
                .onOpenURL { url in
                    // Forward to the shared singleton; the NavHost reacts
                    DeepLinkBus.shared.onUri(uri: url.absoluteString)
                }
        }
    }
}

On Android the same call goes in MainActivity (intent.dataString?.let(DeepLinkBus::onUri)). On the web, deep links live in the URL fragment, so all parameters of the pattern must be URL-encoded and placed after #; toRoute() still parses them when the fragment follows the pattern rules. The Apple-side registration details (universal links, associated domains) are on App Structure and Lifecycle.

Browser History: bindToBrowserNavigation()

A Compose Multiplatform web app is a single page, so by default the browser’s Back button leaves the app instead of popping a screen. NavController.bindToBrowserNavigation() connects the nav graph to the browser: the address bar shows the current route in the URL fragment, Back and Forward move through the app’s back stack, and typing or pasting a URL navigates to the matching destination. It is part of an experimental API surface (@ExperimentalBrowserHistoryApi), so it needs an opt-in, and web itself is Beta.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/App.kt
@Composable
fun App(onNavHostReady: suspend (NavController) -> Unit = {}) {
    TrailMateTheme {
        val navController = rememberNavController()
        TrailMateNavHost(navController = navController)
        LaunchedEffect(navController) { onNavHostReady(navController) }
    }
}
// webApp/src/wasmJsMain/kotlin/com/example/trailmate/main.kt
package com.example.trailmate

import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import androidx.navigation.bindToBrowserNavigation
import kotlinx.browser.document

@OptIn(ExperimentalComposeUiApi::class, ExperimentalBrowserHistoryApi::class)
fun main() {
    val body = document.body ?: return
    ComposeViewport(body) {
        App(onNavHostReady = { it.bindToBrowserNavigation() })
    }
}

With type-safe routes the default URL is the serial name followed by the arguments, for example trailmate.example.com/#trail/42, which is why @SerialName matters. The same binding exists for Kotlin/JS, wrapped in onWasmReady. A custom mapping between routes and URLs is available when the default fragment is not acceptable. The web target itself is covered on Web Targets: Kotlin/Wasm, Kotlin/JS and Compose for Web.

Navigation 3 in Compose Multiplatform

Navigation 3 is a redesign of Android’s navigation library and, since Compose Multiplatform 1.10, works on Android, iOS, desktop and web. It is a new library rather than a new version of Navigation Compose. The key difference: you own the back stack. Instead of a NavController hiding a stack, you hold a SnapshotStateList of keys, mutate it with ordinary list operations, and NavDisplay renders whatever is on top. The Android description of the model, with entry decorators, scenes and results, is on Navigation in Jetpack Compose; this section covers the multiplatform specifics.

# gradle/libs.versions.toml (excerpt)
[versions]
multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-lifecycle = "2.10.0"

[libraries]
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "compose-multiplatform-lifecycle" }

Only navigation3-ui has a separate multiplatform build; navigation3-common arrives transitively.

Routes as NavKey with Polymorphic Serializers

On Android, rememberNavBackStack(RouteA) saves keys with reflection-based serialization. On iOS and the web there is no such reflection, so the saved back stack needs an explicit SerializersModule that lists every NavKey subtype (open polymorphism). Without it the app compiles but fails at runtime on non-JVM targets when the stack is saved or restored. The simplest layout for TrailMate is one sealed interface.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/Nav3Keys.kt
package com.example.trailmate.navigation

import androidx.navigation3.runtime.NavKey
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass

@Serializable
sealed interface TrailKey : NavKey

@Serializable data object TrailListKey : TrailKey
@Serializable data class TrailDetailKey(val id: String) : TrailKey
@Serializable data object SettingsKey : TrailKey

// Required on iOS and web: tells the saved-state machinery how to (de)serialize NavKey
val trailMateSavedState = SavedStateConfiguration {
    serializersModule = SerializersModule {
        polymorphic(NavKey::class) {
            subclass(TrailListKey::class, TrailListKey.serializer())
            subclass(TrailDetailKey::class, TrailDetailKey.serializer())
            subclass(SettingsKey::class, SettingsKey.serializer())
        }
    }
}

The documentation recommends three layouts depending on modularization.

Approach Use when Registration

Single module, sealed type

Every route is in one module (TrailMate today)

sealed interface Route : NavKey, or subclassesOfSealed<Route>() inside polymorphic(NavKey::class)

Multi-module, aggregated sealed types

Each feature module owns a sealed type

The app module lists subclassesOfSealed<FeatureA>() and subclassesOfSealed<FeatureB>(); DI can collect them

Multi-module, individual registration

Routes cannot share a sealed parent

Each module exports a SerializersModule; the app combines them with +

rememberNavBackStack and NavDisplay

// sharedUI/src/commonMain/kotlin/com/example/trailmate/navigation/TrailMateNav3.kt
package com.example.trailmate.navigation

import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.example.trailmate.ui.SettingsRoute
import com.example.trailmate.ui.TrailDetailRoute
import com.example.trailmate.ui.TrailListRoute

@Composable
fun TrailMateNav3() {
    // The overload with SavedStateConfiguration is the multiplatform-safe one
    val backStack = rememberNavBackStack(trailMateSavedState, TrailListKey)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),  // rememberSaveable per entry
            rememberViewModelStoreNavEntryDecorator(),       // ViewModel per entry
        ),
        entryProvider = entryProvider {
            entry<TrailListKey> {
                TrailListRoute(
                    onTrailClick = { id -> backStack.add(TrailDetailKey(id)) },
                    onSettings = { backStack.add(SettingsKey) },
                )
            }
            entry<TrailDetailKey> { key ->
                TrailDetailRoute(trailId = key.id, onBack = { backStack.removeLastOrNull() })
            }
            entry<SettingsKey> { SettingsRoute(onBack = { backStack.removeLastOrNull() }) }
        },
    )
}

backStack.add(…​) is a push and removeLastOrNull() is a pop; any list operation is a legal navigation (clearing the stack and adding the home key is "pop to root"). The two decorators are what give Navigation 3 the per-screen behaviour Navigation Compose provides automatically: the first keeps rememberSaveable state for each entry, the second creates one ViewModelStore per entry so koinViewModel() inside entry<TrailDetailKey> returns a fresh TrailDetailViewModel that is cleared when the key leaves the stack.

The Browser-History Gap

Navigation 3 in CMP does not yet integrate with browser history: there is no bindToBrowserNavigation() for NavDisplay, so Back and Forward on the web leave the app and the address bar does not reflect the screen. The Kotlin documentation says support is planned for the base library and tracks it as CMP-8924. Until then a community proof-of-concept library (navigation3-browser, by a JetBrains engineer) adds the binding, but it is not official; treat it as Experimental in your risk assessment. Deep links are likewise something you implement yourself by mapping the incoming URI to keys and pushing them on the back stack, rather than declaring navDeepLink per destination.

Choosing Between Navigation Compose and Navigation 3

Criterion Navigation Compose (navigation-compose) Navigation 3 (navigation3-ui)

Back stack

Owned by NavController; manipulated through navigate and popBackStack

Owned by you, a SnapshotStateList of NavKey

Routes

@Serializable types; reflection-free

@Serializable types implementing NavKey, plus a polymorphic SerializersModule on non-JVM targets

ViewModel per screen

Automatic

Opt in with rememberViewModelStoreNavEntryDecorator()

Deep links

navDeepLink per destination

Hand-written URI-to-keys mapping

Browser history

bindToBrowserNavigation() (experimental opt-in)

Not available; community library only

Adaptive layouts

Manual

Scenes and the adaptive Navigation 3 artifact

Choose it when

You need web history and declarative deep links today

You want full control, list-detail scenes, or you share a navigation model with a new Android app

For TrailMate the shipped app uses Navigation Compose because the webApp target needs the browser Back button; a project without a web target, or one that accepts the gap, can adopt Navigation 3 and keep the same screens.

Back Handling and the iOS Back Gesture

Android’s BackHandler and PredictiveBackHandler are Android-specific. The multiplatform replacement is the navigationevent library: a common model of navigation events (back, forward, progress of a gesture) that Compose Multiplatform wires to the Android system back and predictive back, to the iOS edge-swipe gesture, and to desktop and web input. In common code you use NavigationEventHandler, which replaces PredictiveBackHandler: it receives the completion of the back event and, in its progress-aware overloads, the gesture progress so a screen can animate along with the finger. The library is published by the AndroidX team for Kotlin Multiplatform; check its release notes for its stability level before relying on progress-aware overloads.

// sharedUI/src/commonMain/kotlin/com/example/trailmate/ui/DiscardChangesHandler.kt
package com.example.trailmate.ui

import androidx.compose.runtime.Composable
import androidx.navigationevent.compose.NavigationEventHandler

@Composable
fun DiscardChangesHandler(hasUnsavedChanges: Boolean, onConfirmDiscard: () -> Unit) {
    // While enabled, back is intercepted instead of popping the screen.
    // Signature shown in its simplest form; progress-aware overloads also exist.
    NavigationEventHandler(
        isBackEnabled = hasUnsavedChanges,
        onBackCompleted = onConfirmDiscard,
    )
}

On iOS there is no back button. Users expect to swipe from the left edge. Compose Multiplatform for iOS supports this gesture for Navigation Compose and Navigation 3 and drives the same navigation event stream, so a NavigationEventHandler that is enabled also blocks the swipe, and the back animation follows the finger. When TrailMate hosts the CMP controller inside a SwiftUI NavigationStack, two back gestures compete: SwiftUI’s own pop and Compose’s. Keep one owner: either let SwiftUI own the stack and make each CMP screen a leaf, or let Compose own everything beneath a single SwiftUI hosting screen. Mixing the two is described on the iOS interop page of this section (see iOS Integration Options for the options, and SwiftUI Navigation and Presentation for the SwiftUI side).

Alternatives: Decompose and Voyager

Two community libraries predate or sit beside the official ones and are worth knowing when you evaluate options.

Aspect Decompose Voyager

Idea

Navigation and lifecycle as plain Kotlin components (ComponentContext), independent of UI; Compose, SwiftUI and Android Views can all render them

Screen classes with a Navigator, ScreenModels and optional Koin integration, Compose-only

Strength

Navigation logic is testable without UI and can drive a SwiftUI UI too (useful on the native UI path)

Very small API, quick to start

Weakness

More ceremony; a different mental model from Jetpack

Compose-only; less aligned with the AndroidX direction

Official docs

Decompose documentation

Voyager documentation

Choose them deliberately: the official libraries gain Android features first and share tooling with the rest of the AndroidX ecosystem, while Decompose is the strongest option when the navigation state must be shared with a SwiftUI UI.

The TrailMate Navigation Graph and Back Stack

flowchart LR subgraph graph["Navigation graph (type-safe routes)"] TL["TrailList
(start destination)"] TD["TrailDetail(id)
deep link trailmate://trail/id"] ST["Settings"] TL -->|"navigate(TrailDetail(id))"| TD TL -->|"navigate(Settings)"| ST TD -->|"popBackStack()"| TL ST -->|"popBackStack()"| TL end DL["Deep link
iOS onOpenURL / Android Intent / web #fragment"] --> TD subgraph stack["Back stack after a deep link (top at right)"] S1["TrailList"] --> S2["TrailDetail(42)"] end BACK["Back: system, iOS swipe,
browser Back (bindToBrowserNavigation)"] -->|"pops"| S2

Common Mistakes

Mistake Fix

Calling viewModel<T>() with no initializer in commonMain

Always pass an initializer or factory, or use koinViewModel(); reflection does not exist on Kotlin/Native.

Navigation 3 ViewModels that never reset

Add rememberViewModelStoreNavEntryDecorator() (and the saveable state decorator) to NavDisplay.

Navigation 3 back stack crashes on iOS or web when saved

Use the rememberNavBackStack(SavedStateConfiguration, …​) overload with a polymorphic SerializersModule.

Coroutines in a ViewModel do nothing on desktop

Add kotlinx-coroutines-swing to jvmMain; Dispatchers.Main is not provided by default.

Relying on ON_DESTROY for cleanup in the web app

It is never reached on web; use DisposableEffect or onPauseOrDispose.

Browser Back leaves the web app

Call bindToBrowserNavigation() (Navigation Compose) and keep @SerialName stable so URLs survive refactors.

Two overlapping navDeepLink patterns

Make the patterns disjoint; overlapping matches behave unpredictably.

Trusting a deep link URI blindly

Validate path segments and ids; treat the URI as untrusted input.

Keeping both SwiftUI and Compose in charge of the back gesture

Choose one owner of the stack per flow.