Skip to main content
https://mintcdn.com/halo-c614fc4d/FJtnvcHaahME2Ayj/images/authors/wathome.png?fit=max&auto=format&n=FJtnvcHaahME2Ayj&q=85&s=f32f12b669897df616d3e8e2eb9636b8

Ethereal Eric

Wanderer @ Thamani
Hero Light
Navigation works best when it’s not mysterious but simple & transparent.
Navigation is a core building block of every app. It sits on every user journey—so if it’s hard to reason about, that complexity leaks into every screen and feature.

Why?

In Nav2, navigation relied on imperative commands, which often acted as ā€œfire-and-forgetā€ events. This created a synchronization gap: if a command was dropped during a configuration change or lifecycle shift, your UI state and your actual screen would drift apart. Nav3 eliminates this ā€œsplit-brainā€ issue by treating the backstack as a declarative state—a simple, observable list that serves as the single source of truth. The benefits of this approach are :
  • The backstack is a standard Kotlin list you own and manage, ensuring the UI always reflects your current data.
  • Navigation transitions are reactive rather than command-based, meaning the UI automatically stays in sync with the underlying state.
  • Complex stack manipulations like reordering, filtering, or clearing history use familiar list operations instead of restrictive API calls.
  • State persistence is built-in, making it significantly easier to handle process death and deep linking without losing the user’s place.

How?

1

Define routes

Use a typesafe way to represent each screen (and its arguments)
@Serializable
sealed interface Routes {
    object Transactions : Routes {
        data class Transaction(val transactionId: String) : Routes
    }
}
2

Create a NavHost with a backstack (the backstack owner)

The backstack is your navigation state
@Composable
fun ThamaniNavHost() {
    val backStack = remember { mutableListOf<Routes>(Routes.Transactions) }

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = { key ->
            when (key) {
                is Routes.Transactions -> {
                    NavEntry(key) {
                        TransactionsRoute(
                            navigateToTransaction = { transactionId ->
                                backStack.add(Routes.Transactions.Transaction(transactionId))
                            },
                        )
                    }
                }

                is Routes.Transactions.Transaction -> {
                    NavEntry(key) {
                        TransactionRoute(
                            transactionId = key.transactionId,
                            navigateBack = { backStack.removeLastOrNull() },
                        )
                    }
                }
            }
        }
    )
}
3

Emit navigation events from the screen via callbacks

Let’s take a lesson from SOLID principles and ensure navigation related functionality is not known by a particular screen (The UI should be dumb and only be responsible for displaying data - Single responsibility principle)Screens shouldn’t be aware what a backstack is, they only emit events and the owner of the backstack reacts accordingly.
@Composable
fun TransactionsRoute(
    navigateToTransaction: (String) -> Unit,
) {
    TransactionsScreen(
        navigateToTransaction = navigateToTransaction
    )
}

@Composable
internal fun TransactionsScreen(
    navigateToTransaction: (String) -> Unit,
    viewModel: TransactionsScreenViewModel = koinViewModel(),
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    val lifecycleOwner = LocalLifecycleOwner.current

    // Handles events sent from the ViewModel. 
    // Cancelled when the event is triggered or the lifecycle owner changes it's state.
    // The viewModel.event here is a sharedFlow that acts as the event emitter.
    LaunchedEffect(key1 = viewModel.event, key2 = lifecycleOwner) {
        // ensures the flow collection only happens when the current lifecycleOwner is at least in the started state.
        // also ensures flow collection is cancelled when the lifecycle owner is in the stop state
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.Started) {
            // ensures flow collection is given priority in the main looper queue
            withContext(Dispatchers.Main.Immediate) {
                // we use collectLatest to ensure only the latest events are collected. This cancels an old event if the collector is slow when a new event is emitted.
                viewModel.event.collectLatest {
                    when (event) {
                        is TransactionsScreenEvent.NavigateToTransaction -> {
                            navigateToTransaction(event.transactionId)
                        }
                    }
                }
            }
        }
    }

    TransactionsScreenContent(
        state = state,
        onAction = viewModel::onAction,
    )
}
At Thamani we wanted to make navigation more clear, so we added a few extension functions for the team
...
// moves forward with a new destination
fun <T> SnapshotStateList<T>.forward(value: T) {
    add(value)
}
// moves backward to the previous destination
fun <T> SnapshotStateList<T>.backward() {
    removeLastOrNull()
}
...
These two functions (although small) helped a lot in reading through the codebase and understanding what every route does
4

Add NavEntryDecorators for the classic android/viewModel behavior

Introducing the ā€œclassicā€ behavior you’re used to (SavedState + ViewModelStore)
In Nav3, NavEntryDecorators are ā€œwrappersā€ (middleware) that get applied to every NavEntry in your back stack. They let you attach the same behavior to all destinations—without baking that behavior into each screen.
@Composable
fun ThamaniNavHost() {
    val backStack = remember { mutableListOf<Routes>(Routes.Transactions) }

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.backward() },

        // NavEntryDecorators for classic behavior (SavedState + ViewModelStore)
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator { true },
        ),
        entryProvider = { key ->
            when (key) {
                is Routes.Transactions -> {
                    NavEntry(key) {
                        TransactionsRoute(
                            navigateToTransaction = { transactionId ->
                                backStack.forward(Routes.Transactions.Transaction(transactionId))
                            },
                        )
                    }
                }
                is Routes.Transactions.Transaction -> {
                    NavEntry(key) {
                        TransactionRoute(
                            transactionId = key.transactionId,
                            navigateBack = { backStack.backward() },
                        )
                    }
                }
            }
        }
    )
}

Conclusion

That’s the whole navigation 3 flow
  • Navigation state is a mutable list to represent the backstack (imagine how much you can achieve with this kind of control)
  • Forward navigation is just adding an item at the end of the list.
  • Back navigation is just removing the last item from the list (again you can decide to play around with the list e.g removing 2 items when a user clicks the back button)
  • NavDisplay renders via entryProvider
  • Entry decorators restore the ā€œclassicā€ ViewModel + SavedState behavior
Remember, with great power comes great responsibility.