> ## Documentation Index
> Fetch the complete documentation index at: https://build.thamani.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Handling Navigation Smoothly

> Building a simple and smooth navigation experience

<Card title="Ethereal Eric" icon="https://mintcdn.com/halo-c614fc4d/FJtnvcHaahME2Ayj/images/authors/wathome.png?fit=max&auto=format&n=FJtnvcHaahME2Ayj&q=85&s=f32f12b669897df616d3e8e2eb9636b8" horizontal width="1000" height="1000" data-path="images/authors/wathome.png">
  Wanderer @ Thamani
</Card>

<img className="block" src="https://mintcdn.com/halo-c614fc4d/FJtnvcHaahME2Ayj/images/engineering/2026/nav3.svg?fit=max&auto=format&n=FJtnvcHaahME2Ayj&q=85&s=23a201c0574c6a8c3219790afde97a0a" alt="Hero Light" width="2064" height="1104" data-path="images/engineering/2026/nav3.svg" />

> 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?

<Steps>
  <Step title="Define routes">
    Use a typesafe way to represent each screen (and its arguments)

    ```kotlin theme={null}
    @Serializable
    sealed interface Routes {
        object Transactions : Routes {
            data class Transaction(val transactionId: String) : Routes
        }
    }
    ```
  </Step>

  <Step title="Create a NavHost with a backstack (the backstack owner)">
    The backstack is your navigation state

    ```kotlin theme={null}
    @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() },
                            )
                        }
                    }
                }
            }
        )
    }
    ```
  </Step>

  <Step title="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.

    ```kotlin theme={null}
    @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

    ```kotlin theme={null}
    ...
    // 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()
    }
    ...
    ```

    <Tip>These two functions (although small) helped a lot in reading through the codebase and understanding what every route does</Tip>
  </Step>

  <Step title="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.

    ```kotlin theme={null}
    @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() },
                            )
                        }
                    }
                }
            }
        )
    }
    ```
  </Step>
</Steps>

## 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.
