100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Kotlin

Passing Arguments Between Screens

How to pass IDs, primitives, and complex data safely between destinations in Navigation Compose using route arguments and shared ViewModels.

NavigationIntermediate9 min readJul 8, 2026
Analogies

Passing Arguments Between Screens

Real apps rarely navigate to a bare screen — tapping a product in a list should open a detail screen for that specific product, which needs the product's ID (or the whole object) to know what to show. Navigation Compose supports this by embedding arguments directly in the route string, similar to how a web URL encodes a path parameter like /products/42, and by declaring the expected argument types on the destination so the framework can extract and validate them.

🏏

Cricket analogy: A stadium's ticket doesn't just say 'Enter Stadium' — it encodes the specific gate, block, and seat number, like /products/42, so the turnstile can validate and route the exact fan; similarly, tapping a specific player's profile needs their player ID embedded in the navigation route to show the right stats.

Defining a route with arguments

A parameterized route is declared with a placeholder in curly braces, e.g. "product/{productId}", and the destination lists a navArgument describing that placeholder's name and type. When navigating, the caller substitutes the real value into the route string; when the destination composes, it reads the value back out of the NavBackStackEntry's arguments bundle.

🏏

Cricket analogy: A scorecard route template like "match/{matchId}" is a blank template until you substitute in the actual fixture number when navigating from the schedule; the match screen then reads that matchId back out of its arguments to fetch the right scorecard.

kotlin
composable(
    route = "product/{productId}",
    arguments = listOf(navArgument("productId") { type = NavType.LongType })
) { backStackEntry ->
    val productId = backStackEntry.arguments?.getLong("productId") ?: 0L
    ProductDetailScreen(productId = productId)
}

// Navigating to it from a list:
@Composable
fun ProductListScreen(products: List<Product>, navController: NavController) {
    LazyColumn {
        items(products, key = { it.id }) { product ->
            ProductRow(
                product = product,
                onClick = { navController.navigate("product/${product.id}") }
            )
        }
    }
}

Optional arguments and query-style parameters

Optional data can be passed as query-style parameters, e.g. "search?query={query}", with navArgument marked nullable = true and given a defaultValue. This mirrors URL query parameters and is useful for filters or search terms that may or may not be present when a screen is opened.

🏏

Cricket analogy: A stadium's search screen accepts an optional filter like "search?team={team}" — you can browse all fixtures without specifying a team, or narrow results by adding one, mirroring an optional URL query parameter with a sensible default when none is given.

Never pass large or complex objects (like a full data class with nested lists) directly through a route string — routes are strings under the hood, and serializing/deserializing large objects through them is fragile and hurts performance. Pass only an ID and re-fetch or look up the full object in the destination's ViewModel.

Sharing data via a shared ViewModel instead

For data that doesn't fit neatly into a route (like a large object selected in one screen but needed in the next), a common alternative is scoping a ViewModel to a shared navigation graph so both screens can access the same instance. Navigation Compose supports this via hiltViewModel(backStackEntry) or navController.getBackStackEntry(route) combined with viewModel(viewModelStoreOwner = ...), letting a parent graph's ViewModel outlive individual screen navigations within that graph.

🏏

Cricket analogy: A team's full match analysis (large object) selected on a summary screen is too big to pass through the scoreboard route string, so both the summary and detail screens share the same 'innings ViewModel' scoped to the match graph — like sharing a scorer's full ledger rather than re-encoding it in a URL.

A useful mental model: route arguments are like function parameters passed by value (small, serializable, IDs or primitives), while a shared ViewModel is like a variable captured by reference in a closure shared by multiple screens — use IDs for the former and full objects for the latter.

Type-safe argument passing

Since Navigation Compose 2.8, routes can be defined as @Serializable data classes (e.g. data class ProductDetail(val productId: Long)), and navController.navigate(ProductDetail(productId = 42)) passes arguments with full compile-time type checking, removing the need to manually build route strings or declare navArgument lists by hand.

🏏

Cricket analogy: Instead of hand-writing a scorecard route string and hoping the fixture ID field name matches, declaring data class MatchDetail(val matchId: Long) lets navController.navigate(MatchDetail(matchId = 42)) catch a typo like passing a String at compile time, before the app ever ships.

  • Route arguments are declared with {placeholder} syntax and a matching navArgument specifying the type.
  • Destinations read arguments back from the NavBackStackEntry's arguments bundle (or via type-safe route objects).
  • Optional/query-style arguments use ?key={key} syntax with nullable = true and a defaultValue.
  • Pass only IDs or small primitives through routes; avoid serializing large objects into route strings.
  • A ViewModel scoped to a shared navigation graph is a better fit for sharing larger or complex data between screens.
  • Type-safe @Serializable route classes remove manual navArgument boilerplate and add compile-time safety.

Practice what you learned

Was this page helpful?

Topics covered

#Kotlin#AndroidWithJetpackComposeStudyNotes#MobileDevelopment#PassingArgumentsBetweenScreens#Passing#Arguments#Between#Screens#StudyNotes#SkillVeris