Passing Arguments Between Screens

The last lesson left RecipeDetailScreen with no idea which recipe the user actually tapped — it's wired into the nav graph, but it receives nothing from the list screen. Almost every real navigation flow needs to carry a value like this from one screen to the next — a recipe ID, a search query, a user ID — and Navigation Compose handles it by putting a placeholder directly in the route string.

Defining a Route With a Placeholder

A route argument is written as {argName} inside the route string, exactly like a path parameter in a URL. RecipeBox's detail route needs a recipe ID, so its route becomes:

composable("recipeDetail/{recipeId}") { backStackEntry ->
    // backStackEntry gives access to the actual value passed for {recipeId}
}

The content lambda passed to composable() receives a NavBackStackEntry, which is where the actual argument value lives once someone navigates to this route.

Reading the Argument

Arguments are read off backStackEntry.arguments, a Bundle, using the same argument name that appeared in the route's placeholder:

composable("recipeDetail/{recipeId}") { backStackEntry ->
    val recipeId = backStackEntry.arguments?.getString("recipeId")
    RecipeDetailScreen(recipeId = recipeId)
}

getString("recipeId") returns a nullable String — there's no compile-time guarantee that a value was actually supplied, which is one of the trade-offs of string-based routes covered below.

Navigating With the Actual Value

To navigate to a parameterized route, you build the concrete route string by substituting the real value in place of the placeholder:

navController.navigate("recipeDetail/${recipe.id}")

If recipe.id is "r42", this navigates to the concrete route "recipeDetail/r42", which matches the "recipeDetail/{recipeId}" pattern registered in the NavHost, with recipeId bound to "r42".

Worked Example: RecipeBox List → Detail With an ID

NavHost(navController = navController, startDestination = "recipeList") {
    composable("recipeList") {
        RecipeListScreen(
            recipes = recipes,
            onRecipeClick = { recipeId ->
                navController.navigate("recipeDetail/$recipeId")
            }
        )
    }
    composable("recipeDetail/{recipeId}") { backStackEntry ->
        val recipeId = backStackEntry.arguments?.getString("recipeId")
        val recipe = recipes.find { it.id == recipeId }
        if (recipe != null) {
            RecipeDetailScreen(recipe = recipe, onBackClick = { navController.popBackStack() })
        }
    }
}

RecipeListScreen's onRecipeClick now hands back the tapped recipe's ID (see the previous lesson's RecipeRow click handler), the route carries it as text, and the detail composable looks the matching Recipe up from the ID it reads back out.

Looking Ahead: Type-Safe Navigation

String routes like "recipeDetail/{recipeId}" work, but they have a real weakness: the compiler can't check that the route string matches the arguments you read from it. Typo "recipieId" in one spot and not the other, and you get a silent, wrong result at runtime rather than a compile error. Newer versions of Navigation Compose add a type-safe alternative built on Kotlin Serialization: instead of string routes, each destination is a plain @Serializable Kotlin object or data class — RecipeBox's detail route would become something like @Serializable data class RecipeDetail(val recipeId: String) — and you navigate with navController.navigate(RecipeDetail(recipeId = recipe.id)) and read the argument back as a fully-typed object, with the compiler verifying everything matches. It's the same underlying idea as this lesson's placeholders, with the string-matching risk removed.

Note: As throughout this track, this is correct, static Kotlin/Compose code — this website's own project doesn't run a live Android Studio build, so it isn't executing on this page.
Next up: Bottom Navigation covers a different, very common navigation shape — switching between top-level destinations with a persistent bottom bar.