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.