Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 021 / 100

Jetpack Compose navigation: patterns, pitfalls and fixes

Move between screens.

Expona Admin7 min read

Why navigation matters in modern Android apps

Navigation is the connective tissue of any Android app. Get it wrong and users hit unexpected back behavior, lose their place in flows, or watch data reload unnecessarily. Jetpack Compose navigation: patterns, pitfalls and fixes addresses the most common stumbling blocks we encounter when moving between screens in production Compose apps—from untyped route strings that slip through without warning to improper back stack management that breaks login flows.

The Navigation Compose library gives us powerful tools, but it rewards discipline. We'll walk through what we've learned from building e-commerce, CRM, and SaaS features at scale.

NavHost is your navigation graph. It declares every destination your app can reach and wires up the navigation logic. Rather than scattering navigation calls across multiple composables, centralizing routes in NavHost makes the user journey explicit and testable.

Think of NavHost as a router configuration file. You define all screens, their routes, and transitions in one composable high in your hierarchy—typically in your activity or main app composable. This creates a single source of truth for navigation.

kotlin
@Composable
fun AppNavigation(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onProductClick = { productId ->
                    navController.navigate("product/$productId")
                }
            )
        }
        
        composable("product/{productId}") { entry ->
            val productId = entry.arguments?.getString("productId")
            ProductDetailScreen(productId = productId)
        }
        
        composable("checkout") {
            CheckoutScreen()
        }
    }
}

The NavHost handles backstack automatically. Each call to navigate() pushes a destination. Pressing back pops the current screen and restores the previous one. However, relying on string-based routes like "product/{productId}" is fragile. You lose type safety, and it's easy to pass the wrong argument shape or forget a required parameter. This is where serializable routes come in.

Type-safe routes — Use serializable route classes

String-based routes work, but they're error-prone. The Navigation Compose library now supports type-safe route arguments through Kotlin serialization. Instead of string concatenation, you define route classes and let the compiler verify your arguments.

With serializable routes, navigation becomes a compile-time contract. You can't accidentally pass a wrong type or forget a parameter. The IDE autocompletes your navigation calls, and refactoring is safe.

kotlin
import kotlinx.serialization.Serializable

@Serializable
data class Product(val id: String)

@Serializable
data class Order(val orderId: String, val status: String = "pending")

@Composable
fun AppNavigation(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Home
    ) {
        composable {
            HomeScreen(
                onProductClick = { productId ->
                    navController.navigate(Product(id = productId))
                }
            )
        }
        
        composable { entry ->
            val product = entry.toRoute()
            ProductDetailScreen(productId = product.id)
        }
        
        composable { entry ->
            val order = entry.toRoute()
            OrderSummaryScreen(orderId = order.orderId, status = order.status)
        }
    }
}

// In HomeScreen
@Composable
fun HomeScreen(onProductClick: (String) -> Unit) {
    Button(onClick = { onProductClick("abc123") }) {
        Text("View Product")
    }
}

The toRoute() extension deserializes the navigation argument into your route class. The Kotlin compiler verifies types at build time. If you try to navigate with the wrong class or forget a required field, you get a compile error—not a runtime crash.

You'll need to add Kotlin Serialization to your build.gradle:

kotlin
plugins {
    kotlin("plugin.serialization") version "1.9.0"
}

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0"
    implementation "androidx.navigation:navigation-compose:2.7.4"
}

Pass IDs, not objects — Load data in the destination's ViewModel

A common mistake is passing entire domain objects through navigation arguments. This tempts you to share state across screens in ad-hoc ways, makes testing harder, and couples your navigation to data layer details.

Instead, pass only primitive IDs—strings, numbers, UUIDs—and let the destination screen fetch its own data via a ViewModel. This keeps screens independent and respects the single responsibility principle.

kotlin
// Bad: Passing the whole object
@Serializable
data class ProductRoute(val product: Product) // Don't do this

// Good: Passing only the ID
@Serializable
data class ProductRoute(val id: String)

@Composable
fun AppNavigation(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Home
    ) {
        composable { entry ->
            val route = entry.toRoute()
            ProductDetailScreen(productId = route.id)
        }
    }
}

// ProductDetailScreen and its ViewModel
@Composable
fun ProductDetailScreen(
    productId: String,
    viewModel: ProductDetailViewModel = hiltViewModel()
) {
    val product by viewModel.product.collectAsState()
    
    LaunchedEffect(productId) {
        viewModel.loadProduct(productId)
    }
    
    product?.let { p ->
        ProductContent(p)
    }
}

@HiltViewModel
class ProductDetailViewModel @Inject constructor(
    private val productRepository: ProductRepository
) : ViewModel() {
    private val _product = MutableStateFlow(null)
    val product = _product.asStateFlow()
    
    fun loadProduct(id: String) {
        viewModelScope.launch {
            _product.value = productRepository.fetchProduct(id)
        }
    }
}

This pattern has several benefits. First, the ProductDetailScreen is now testable in isolation—you can pass different product IDs and verify the ViewModel loads correctly. Second, if the product data needs to be shared across multiple screens, that logic lives in a shared ViewModel or repository, not scattered across route arguments. Third, screen composition stays lightweight; you're not bloating navigation arguments with serialized objects that may contain circular references or non-serializable types.

Back stack — popUpTo controls what back does after login

The back stack is a LIFO queue. Each navigate() call pushes a new screen. Pressing back pops the current screen and shows the previous one. This works naturally for most flows, but breaks in certain scenarios—especially login and deep links.

Imagine a user logs in. The auth flow is Home → Login → Dashboard. After login, pressing back should not return to the login screen; it should go to the app's home or exit. That's where popUpTo comes in. It removes entries from the back stack up to (and optionally including) a specified destination.

kotlin
@Serializable object Home
@Serializable object Login
@Serializable object Dashboard

@Composable
fun AppNavigation(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Home
    ) {
        composable {
            HomeScreen(
                onLoginClick = {
                    navController.navigate(Login)
                }
            )
        }
        
        composable {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Dashboard) {
                        popUpTo { inclusive = false }
                    }
                }
            )
        }
        
        composable {
            DashboardScreen(
                onLogout = {
                    navController.navigate(Home) {
                        popUpTo { inclusive = true }
                    }
                }
            )
        }
    }
}

In the login success scenario, popUpTo { inclusive = false } removes all entries above Home (including Login itself), then pushes Dashboard. Now the back stack is [Home, Dashboard]. Pressing back from Dashboard returns to Home, not Login.

On logout, popUpTo { inclusive = true } removes Dashboard and everything above it, then pushes Home. The back stack is now [Home]. This is often paired with launchSingleTop = true to avoid duplicate Home instances.

kotlin
onLogout = {
    navController.navigate(Home) {
        popUpTo { inclusive = true }
        launchSingleTop = true
    }
}

This pattern ensures users can't accidentally swipe or tap back into restricted flows. It's essential for authentication, one-time onboarding sequences, and deep link handling.

Common mistakes

  • Forgetting to consume arguments in composable: You pass an ID through the route, but the composable doesn't extract it via toRoute(). The parameter arrives null, and the screen loads blank data. Always check that entry.toRoute() is called in the destination composable lambda.
  • Passing serializable objects instead of IDs: Trying to serialize domain models with complex nested objects, non-serializable fields, or circular references. The serialization fails silently or at runtime. Stick to primitives or simple data classes with only serializable fields.
  • Neglecting the back stack in critical flows: After login, users can still press back to re-enter the login screen. Always use popUpTo to sanitize the back stack after authentication or one-time flows.
  • Using string-based routes in production: Typos in route strings or argument names cause navigation failures that only surface in testing or production. Migrate to serializable route classes early.
  • Loading data in the composable itself: Fetching data in a LaunchedEffect without a ViewModel means state is lost on recomposition. Always use a ViewModel to hold screen state, and only pass IDs through navigation.

Key takeaways

  • Centralize all navigation logic in NavHost. It's your single source of truth for screen destinations and transitions.
  • Use @Serializable route classes instead of string-based routes to get compile-time type safety and prevent navigation errors.
  • Pass only IDs and primitives through navigation arguments. Load full data objects in the destination's ViewModel.
  • popUpTo is essential for controlling back stack behavior in login flows, deep links, and one-time onboarding. Always clean up the back stack after critical transitions.
  • Test navigation paths independently. Mock the NavController and verify that each screen receives the correct route arguments and loads the right data.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles