DEV Community

Cover image for I Had No Idea How My Screens Were Talking to Each Other. Then I Learned Compose Navigation.
Aalaa Fahiem
Aalaa Fahiem

Posted on

I Had No Idea How My Screens Were Talking to Each Other. Then I Learned Compose Navigation.

Let me be honest with you.

The first time I tried to navigate between two screens in Jetpack Compose, I copy-pasted code from Claude, it worked, and I moved on. I had zero idea what NavHost, NavController, or BackStack actually meant.

Then my app had 5 screens. Then 8. Then I needed to pass data between them. Then everything broke, and I finally had to sit down and actually learn this.

This article is what I wish someone had explained to me back then.

We're building a simple e-commerce app — Product List → Product Detail → Cart — and we're going to understand every single line of navigation code we write. No copy-pasting in the dark this time.


First, What Does "Navigation" Even Mean?

Before we write a single line of code, let's make sure we actually understand what's happening.

Imagine your phone screen is a stack of physical cards on a table:

  • You open the app → the first card (Product List) is placed on the table.
  • You tap a product → a new card (Product Detail) is placed on top of the first one.
  • You press back → that top card is removed, and you see the Product List again.

That pile of cards? That's your Back Stack.

Android manages this stack automatically. Your job is just to tell it: "add this screen" or "remove the top one." That's navigation.

Simple. Let's build it.


🔧 Setup

Add this dependency to your build.gradle.kts (app level):

dependencies {
    implementation("androidx.navigation:navigation-compose:2.9.0")
}
Enter fullscreen mode Exit fullscreen mode

Sync and you're ready.


Meet the 3 Players

Compose Navigation has three key pieces. Think of them like a team with very specific roles:

Who What They Do
NavController The pilot — decides where you go
NavHost The airport — the container that holds all destinations
composable() The gate — registers each screen as a destination

Let's see them in action.

rememberNavController()

This creates your NavController and keeps it alive across recompositions:

val navController = rememberNavController()
Enter fullscreen mode Exit fullscreen mode

Think of this as creating the remote control for your whole app's navigation.

NavHost + composable()

This is where you declare every screen your app has:

NavHost(
    navController = navController,
    startDestination = "product_list"
) {
    composable("product_list") { ProductListScreen() }
    composable("product_detail") { ProductDetailScreen() }
    composable("cart") { CartScreen() }
}
Enter fullscreen mode Exit fullscreen mode

startDestination is just the screen that shows up first when the app launches. Everything else gets added when the user navigates there.

That's the whole system. Now let's make it production-quality.


Stop Hardcoding Strings — Use a Sealed Class

Right now, our routes are raw strings like "product_list".

Here's the problem: you'll use that string in multiple places — the NavHost, the navigate call, the popBackStack call... One typo anywhere? App crashes at runtime with zero help from the compiler.

The fix is a sealed class that acts as a single source of truth for all your routes:

sealed class Screen(val route: String) {

    object ProductList : Screen("product_list")
    object Cart : Screen("cart")

    // Screens with arguments get their own createRoute() function
    object ProductDetail : Screen("product_detail/{productId}") {
        fun createRoute(productId: Int) = "product_detail/$productId"
    }
}
Enter fullscreen mode Exit fullscreen mode

Now if you ever rename a screen, you change it in one place and every reference updates automatically. The compiler is your safety net.


Setting Up the Full NavHost

With the sealed class in place, your NavHost becomes clean and readable:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.ProductList.route
    ) {

        composable(Screen.ProductList.route) {
            ProductListScreen(navController)
        }

        composable(
            route = Screen.ProductDetail.route,
            arguments = listOf(
                navArgument("productId") { type = NavType.IntType }
            )
        ) { backStackEntry ->
            val productId = backStackEntry.arguments?.getInt("productId") ?: 0
            ProductDetailScreen(productId = productId, navController = navController)
        }

        composable(Screen.Cart.route) {
            CartScreen(navController)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how ProductDetail declares an arguments list — that's how you tell the NavHost "hey, this screen expects some data to arrive with it."


Navigating to a Screen

Now let's make the user actually go somewhere. When a product is tapped in the list, we navigate to its detail screen:

@Composable
fun ProductListScreen(navController: NavController) {

    val products = listOf(
        Product(id = 1, name = "Air Max 90", price = 120.0),
        Product(id = 2, name = "Jordan 1 Retro", price = 180.0),
        Product(id = 3, name = "New Balance 550", price = 95.0)
    )

    LazyColumn {
        items(products) { product ->
            ProductItem(
                product = product,
                onClick = {
                    // 👇 This is all navigation is — "go here"
                    navController.navigate(Screen.ProductDetail.createRoute(product.id))
                }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

createRoute(product.id) produces "product_detail/1" — it fills in the argument in the route template. Clean, no string manipulation in your UI code.


Passing Data: The Simple Way (ID)

The golden rule of navigation: pass the minimum data needed.

Usually that's just an ID. The receiving screen fetches the full data using that ID (from a ViewModel, a repository, wherever). This is the officially recommended approach.

@Composable
fun ProductDetailScreen(
    productId: Int,
    navController: NavController,
    viewModel: ProductViewModel = hiltViewModel()
) {
    // Fetch the full product using the ID
    val product by viewModel.getProduct(productId).collectAsState()

    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = product?.name ?: "Loading...",
            style = MaterialTheme.typography.headlineMedium
        )
        Text(text = "$${product?.price}")

        Spacer(modifier = Modifier.height(16.dp))

        Button(
            onClick = { navController.navigate(Screen.Cart.route) }
        ) {
            Text("Add to Cart 🛒")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

ID goes in → ViewModel fetches the product → UI shows it. Simple and predictable.

Going Back

Three flavors of going back, depending on what you need:

// 1. Go back one screen — the most common case
navController.popBackStack()

// 2. Go back to a specific screen (keeps it in the stack)
navController.popBackStack(
    route = Screen.ProductList.route,
    inclusive = false  // false = keep ProductList on the stack, land on it
)

// 3. Go back and clear everything including the destination
navController.popBackStack(
    route = Screen.ProductList.route,
    inclusive = true  // true = remove ProductList too (useful for login → home flows)
)
Enter fullscreen mode Exit fullscreen mode

The inclusive flag is the one that trips everyone up. A simple way to remember it:

  • inclusive = false"go back TO this screen"
  • inclusive = true"go back PAST this screen"

🧩 The Complete Picture

Let's look at everything together. Here's your final AppNavigation.kt:

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Screen.ProductList.route
    ) {

        composable(Screen.ProductList.route) {
            ProductListScreen(navController)
        }

        composable(
            route = Screen.ProductDetail.route,
            arguments = listOf(
                navArgument("productId") { type = NavType.IntType }
            )
        ) { backStackEntry ->
            val productId = backStackEntry.arguments?.getInt("productId") ?: 0
            ProductDetailScreen(
                productId = productId,
                navController = navController
            )
        }

        composable(Screen.Cart.route) {
            CartScreen(navController)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And your sealed class:

sealed class Screen(val route: String) {
    object ProductList : Screen("product_list")
    object Cart : Screen("cart")
    object ProductDetail : Screen("product_detail/{productId}") {
        fun createRoute(productId: Int) = "product_detail/$productId"
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a complete, production-ready navigation setup. No magic. No mystery.


The next time someone mentions the Back Stack, NavHost, or sealed class routes — you'll know exactly what they mean and why it's set up that way.

What are you building with Compose Navigation? Drop it in the comments 👇

And if this saved you from a Claude rabbit hole, share it with the Android dev who needs it right now. 🙌

Top comments (0)