Android with Kotlin and Jetpack Compose · Architecture and Data: ViewModels, Navigation, Networking and Offline

Navigation with Navigation 3

Move between screens with Navigation 3: a back stack you own as a list of keys, NavDisplay to show it, typed keys with arguments, and back handling.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

Three screens (a product list, a product detail that receives the product ID, and a cart) with the system back gesture working, and the back stack surviving rotation and process death.

The idea

Navigation 3 (androidx.navigation3, stable since 2025) is the Compose-first navigation library. Its central idea is simple: the back stack is a list of keys that you own. Navigating forward adds a key; going back removes the last one. NavDisplay observes the list and shows the screen for the top key.

  • A key is a small serializable object or data class implementing NavKey; arguments are just its properties, so they're type-safe.
  • rememberNavBackStack(startKey) creates a back stack that's saved across configuration changes and process death.
  • entryProvider { entry<Key> { key -> ... } } maps each key type to its content.
  • onBack handles the system back gesture.

Because the back stack is ordinary state, logic such as "clear back to the list after checkout" or "don't open the same product twice" is just list manipulation.

The code

@Serializable data object ProductList : NavKey
@Serializable data class ProductDetail(val productId: String) : NavKey
@Serializable data object Cart : NavKey

@Composable
fun ShopNavigation() {
    val backStack = rememberNavBackStack(ProductList)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryProvider = entryProvider {
            entry<ProductList> {
                ProductListScreen(onOpen = { id -> backStack.add(ProductDetail(id)) })
            }
            entry<ProductDetail> { key ->
                ProductDetailScreen(
                    productId = key.productId,
                    onOpenCart = { backStack.add(Cart) },
                )
            }
            entry<Cart> {
                CartScreen(onCheckoutDone = {
                    backStack.retainAll { it == ProductList }   // back to the start
                })
            }
        },
    )
}

Dependencies: androidx.navigation3:navigation3-runtime and navigation3-ui, the kotlinx.serialization plugin, and androidx.lifecycle:lifecycle-viewmodel-navigation3 to scope a ViewModel to each entry.

Try the logic

The back stack is a list, so its rules can be written and checked as plain Kotlin:

Back stack operations
sealed interface Key
data object ProductList : Key
data class ProductDetail(val productId: String) : Key
data object Cart : Key

class BackStack(start: Key) {
    val keys = mutableListOf(start)
    fun navigate(key: Key) {
        if (keys.last() != key) keys.add(key)       // ignore double taps on the same destination
    }
    fun back(): Boolean = if (keys.size > 1) { keys.removeLast(); true } else false
    fun popTo(key: Key) { while (keys.last() != key && keys.size > 1) keys.removeLast() }
    override fun toString() = keys.joinToString(" > ")
}

fun main() {
    val stack = BackStack(ProductList)
    stack.navigate(ProductDetail("mug-01"))
    stack.navigate(ProductDetail("mug-01"))          // double tap
    stack.navigate(Cart)
    println(stack)
    stack.back()
    println("after back: $stack")
    stack.navigate(Cart)
    stack.popTo(ProductList)                          // checkout finished
    println("after checkout: $stack")
    println("back on the start screen closes the app: ${!stack.back()}")
}

Output

ProductList > ProductDetail(productId=mug-01) > Cart
after back: ProductList > ProductDetail(productId=mug-01)
after checkout: ProductList
back on the start screen closes the app: true

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Passing whole objects as arguments. Pass IDs and load the data in the destination's ViewModel; keys must be small and serializable.
  • Forgetting @Serializable or NavKey. The back stack can't be saved and the app loses its place after process death.
  • Navigating from a ViewModel by holding the back stack. Expose an event or callback; the UI layer owns navigation.
  • Deep links that skip the parent screen. Build the back stack a user expects (list, then detail) when opening from a link or notification.

Key points

  • Navigation 3 treats the back stack as a list of keys you own.
  • NavDisplay shows the top entry; entryProvider maps key types to content.
  • Keys are serializable and carry type-safe arguments; pass IDs, not objects.
  • Back-stack rules are plain list logic, easy to test.

Further reading: Navigation 3, Save and manage navigation state.

Practice questions

How is Navigation 3 different from the earlier Navigation component?

Answer

In Navigation 3 the back stack is ordinary state you own (a list of keys), and NavDisplay renders it; you navigate by adding and removing keys. The earlier Navigation component kept the back stack inside a NavController with a navigation graph of routes, and you navigated by calling the controller.

Why should navigation arguments be IDs rather than full objects?

Answer

Keys are saved in the back stack (so they must be small and serializable) and objects can go stale. Passing an ID lets the destination load fresh data from the repository, which is the single source of truth.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?