DevBits
DevBitsAndroid Handbook
Jetpack Compose

How do NavHost and NavController work?

Understand how Navigation Compose keeps navigation history and turns that state into the screen the user sees.

CMP-018Intermediate~45 sec

What the interviewer is testing

This question is really checking whether you understand the responsibilities behind navigation, rather than just knowing how to call navigate().

The important distinction is that NavController manages navigation state and the back stack, while NavHost connects that state to the destinations the UI can show.

If you understand that relationship, the individual Navigation Compose APIs become much easier to reason about.

❌ Common mistake

If they ask you to elaborate

Imagine a shopping app:

Products → ProductDetails → Checkout

At the top level, the app creates a NavController and connects it to a NavHost:

val navController = rememberNavController()

NavHost(
    navController = navController,
    startDestination = Products
) {
    composable<Products> {
        ProductListScreen(
            onProductClick = { productId ->
                navController.navigate(ProductDetails(productId))
            }
        )
    }

    composable<ProductDetails> { backStackEntry ->
        val route = backStackEntry.toRoute<ProductDetails>()

        ProductDetailsScreen(
            productId = route.productId,
            onBack = navController::popBackStack,
            onCheckout = {
                navController.navigate(Checkout)
            }
        )
    }

    composable<Checkout> {
        CheckoutScreen(
            onBack = navController::popBackStack
        )
    }
}

There are a few pieces working together here.

The NavHost defines the destinations this part of the app knows about. Each composable entry connects a route to the UI that should be shown for that destination.

The NavController keeps track of where the user currently is and the navigation history behind that destination.

So when the user taps a product, ProductListScreen does not replace the UI itself. It reports what happened:

onProductClick(productId)

The navigation layer turns that into:

navController.navigate(ProductDetails(productId))

The controller updates its navigation state and ProductDetails becomes the current destination. Because the NavHost is connected to that controller, it shows the composable registered for ProductDetails.

You can picture the whole flow as:

user taps product

onProductClick(productId)

navController.navigate(ProductDetails(productId))

NavController updates the back stack

ProductDetails becomes the current destination

NavHost shows ProductDetailsScreen

The back stack might now look like:

Products
ProductDetails("42")

If the user continues to checkout:

navController.navigate(Checkout)

the stack becomes:

Products
ProductDetails("42")
Checkout

Then popBackStack() removes Checkout. ProductDetails("42") becomes current again, so the NavHost shows that destination.

That is the relationship worth understanding. NavHost and NavController are not two unrelated APIs: one describes and hosts the destinations, while the other coordinates moving through them.

Production thinking

Now imagine we're building that shopping app for production.

It starts small:

Products

ProductDetails

Checkout

It can be tempting to pass the NavController into every screen:

@Composable
fun ProductListScreen(navController: NavController) {
    // ...
}

That works, but now ProductListScreen needs to know how the app navigates. As the screen grows, navigation decisions start leaking into UI code.

A cleaner boundary is for the screen to report what happened:

@Composable
fun ProductListScreen(
    onProductClick: (String) -> Unit
) {
    // ...
}

Then the navigation layer decides what that event means:

composable<Products> {
    ProductListScreen(
        onProductClick = { productId ->
            navController.navigate(ProductDetails(productId))
        }
    )
}

The distinction is small, but useful.

The screen knows a product was selected. The navigation layer knows selecting a product should open ProductDetails.

That also makes the screen easier to preview, reuse and test because it does not need a real NavController just to react to a click.

The same idea applies to the data carried by a route. Suppose the user opens product 42.

It may be tempting to pass the entire Product through navigation. Usually the route only needs enough information to identify what should be opened:

@Serializable
data class ProductDetails(
    val productId: String
)

Then:

navController.navigate(ProductDetails(productId))

The destination can use that ID to get the current product data from the appropriate state holder or data layer.

That keeps navigation focused on where the user is going, rather than turning the back stack into another place for carrying application data.

Now let the app grow:

Home
├── Search
├── Products
│   ├── ProductDetails
│   └── Reviews
├── Cart
│   └── Checkout
└── Account
    ├── Orders
    └── Settings

The mental model has not changed. There is still a NavController coordinating navigation state and a NavHost showing the current destination.

What usually changes is how the graph is organised.

Instead of leaving dozens of destinations in one huge block, related destinations can be grouped into smaller navigation-builder functions:

NavHost(
    navController = navController,
    startDestination = Home
) {
    homeGraph(navController)
    productGraph(navController)
    checkoutGraph(navController)
    accountGraph(navController)
}

You do not need that structure on day one. It becomes useful when the graph is large enough that one navigation file is difficult to understand or change safely.

The practical rule is to keep navigation decisions near the navigation layer, let screens report user actions through callbacks, and pass small route arguments such as IDs rather than making navigation carry whole application objects.

Then when somebody asks, "What should Back do from here?" or "Where should this deep link land?", you can reason about the navigation graph and back stack instead of hunting through screens for scattered navigate() calls.

Follow-up questions

What separates a senior answer

Key takeaways

Learn more

On this page