Passing Arguments Between Compose Destinations

Quick answer: Put the arguments a destination needs in an
@Serializableroute data class, create that route when navigating, and callbackStackEntry.toRoute<YourRoute>()in the receiving destination. Pass a small stable value such as an ID, not a full model object. With Navigation 2.8.0 and newer, this is the built-in type-safe Navigation Compose pattern.
Passing an argument is a two-sided contract. The destination must declare what it accepts, and the source must provide a value that can be restored and decoded later. Type-safe routes make both sides explicit in Kotlin:
@Serializable
data class Product(
val productId: String,
)
navController.navigate(Product(productId = "sku-42"))
composable<Product> { entry ->
val product = entry.toRoute<Product>()
ProductScreen(productId = product.productId)
}This article expands that small example to multiple arguments, optional values, ViewModels, lists, and migration from string routes. If you need the complete graph setup first, start with Navigation Compose: Create Your First NavHost. For the route model and dependency setup, see Type-Safe Navigation Compose with Kotlin Serialization.
Define the receiving destination’s arguments
With type-safe Navigation Compose, the constructor of a serializable route is the destination’s argument definition:
import kotlinx.serialization.Serializable
@Serializable
data class Product(
val productId: String,
)Use an object or data object for a destination with no arguments. Use a class or data class when the destination receives values. Every route type used by the type-safe APIs must have @Serializable, and the Kotlin serialization plugin must be applied to the module that owns the route types.
Navigation’s type-safe route APIs are available from Navigation 2.8.0. The official type-safety documentation shows the same object/data-class distinction and the toRoute() API.
Navigate with a route instance
The source destination creates a route instance and passes the argument through its constructor:
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@Composable
fun ProductListScreen(
onOpenProduct: (String) -> Unit,
) {
Button(onClick = { onOpenProduct("sku-42") }) {
Text("View product")
}
}The navigation graph connects that callback to the typed route:
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
fun NavGraphBuilder.productGraph(
navController: NavHostController,
) {
composable<Product> { entry ->
val route = entry.toRoute<Product>()
ProductScreen(
productId = route.productId,
onBack = navController::popBackStack,
)
}
}When the source is wired inside a NavHost, the call is simply:
ProductListScreen(
onOpenProduct = { productId ->
navController.navigate(Product(productId))
},
)The source does not assemble "product/$productId", and the receiver does not look up a string key from a Bundle. The route data class is the shared contract.
Read arguments in a NavHost
Here is a complete small graph showing where the argument is decoded:
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
@Serializable
data object Products
@Composable
fun ProductNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Products,
) {
composable<Products> {
ProductListScreen(
onOpenProduct = { productId ->
navController.navigate(Product(productId))
},
)
}
composable<Product> { entry ->
val product = entry.toRoute<Product>()
ProductScreen(
productId = product.productId,
onBack = { navController.popBackStack() },
)
}
}
}toRoute() reconstructs the route from the current NavBackStackEntry. Decode it once at the destination boundary, then pass the resulting values into screen UI. A screen can remain a normal composable that accepts productId and callbacks instead of knowing about navigation internals.
Pass multiple arguments
Add each required value as a property of the route type:
@Serializable
data class SearchResults(
val query: String,
val categoryId: String,
val page: Int,
)Navigate by naming the properties when there is more than one value:
navController.navigate(
SearchResults(
query = "headphones",
categoryId = "audio",
page = 1,
),
)Read the same properties at the destination:
composable<SearchResults> { entry ->
val route = entry.toRoute<SearchResults>()
SearchResultsScreen(
query = route.query,
categoryId = route.categoryId,
page = route.page,
)
}This is useful for a destination whose identity depends on several small values, such as a search query plus a selected category. Keep the route focused on navigation inputs. Sort order, loading state, and fetched results are usually screen state and belong in the destination’s state holder instead.
Use optional arguments carefully
An optional route property can be nullable and have a default value:
@Serializable
data class Search(
val query: String? = null,
)Both forms are valid route instances:
navController.navigate(Search())
navController.navigate(Search(query = "compose"))Use an optional argument when the destination genuinely has one route identity with optional input. Do not use defaults to hide a required business decision. If a screen cannot work without an ID, make that ID non-nullable and require it in the constructor.
Optional arguments also need a restoration test in the app. Verify both the omitted and populated forms after process recreation, because the route must be reconstructed from saved navigation state rather than only from the original click event.
Pass arguments to a ViewModel
When a destination loads data in a ViewModel, read the typed route from SavedStateHandle:
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
class ProductViewModel(
savedStateHandle: SavedStateHandle,
private val productRepository: ProductRepository,
) : ViewModel() {
private val route = savedStateHandle.toRoute<Product>()
val product = productRepository.observeProduct(route.productId)
}The repository and its return type are application-specific. The important boundary is that the ViewModel receives the route’s small identifier, then loads the current product from the data layer.
This approach avoids passing a full Product object through navigation. Android’s guidance on passing data between destinations recommends passing the minimum necessary data, such as a key used to retrieve an object, because saved-state space is limited. The Compose migration guidance makes the same recommendation for complex objects.
Do not pass large objects through routes
Prefer this:
@Serializable
data class OrderDetails(
val orderId: String,
)
navController.navigate(OrderDetails(orderId = order.id))Then let the destination load the order by ID. Avoid encoding a full order, bitmap, or large list into a route. It duplicates data, makes updates harder to reason about, and increases the amount of state Navigation must save and restore.
If two destinations need to share a large or mutable object, use a repository as the source of truth or a ViewModel scoped to the appropriate navigation graph. Navigation identifies where to go; it should not become your data transport layer.
Handle lists and collection arguments
Some simple collection types are supported by Navigation’s type-safe route machinery in current Navigation versions. For example, a route may represent a small set of selected IDs:
@Serializable
data class CompareProducts(
val productIds: List<String>,
)Use collection arguments only when the collection is small, stable, and part of the destination’s identity:
navController.navigate(
CompareProducts(productIds = listOf("sku-1", "sku-2")),
)Do not treat this as permission to pass arbitrary application state. For a large result set or frequently changing collection, pass a query, filter, or saved key and load the data in the destination. If you need a custom value type, register a custom NavType mapping and test serialization, parsing, restoration, and malformed input explicitly.
Migrate string arguments to typed routes
An older graph often defines a placeholder and manually extracts the value:
composable("product/{productId}") { entry ->
val productId = entry.arguments?.getString("productId")
ProductScreen(productId = requireNotNull(productId))
}
navController.navigate("product/$productId")Replace both sides with one serializable route:
@Serializable
data class Product(
val productId: String,
)
composable<Product> { entry ->
val product = entry.toRoute<Product>()
ProductScreen(productId = product.productId)
}
navController.navigate(Product(productId))Migrate one destination at a time. Remove the old placeholder and argument key after all callers use the new route. During a staged migration, it is acceptable for unrelated destinations to remain string-based, but avoid keeping two representations of the same destination.
Common mistakes
Passing the model instead of its key
Pass productId, not the whole Product. The receiving destination should load the model from the current source of truth.
Decoding the argument in every child composable
Call toRoute<T>() at the navigation boundary. Pass ordinary values and callbacks to child UI so previews and tests do not need a NavBackStackEntry.
Reusing a route type for unrelated screens
A route type should describe one destination’s navigation inputs. Two screens that happen to accept a String may still have different meanings and should have distinct route types.
Treating optional arguments as screen state
An argument is part of the destination identity and back-stack entry. Temporary text-field content, loading flags, and fetched results usually belong in screen state; see State Hoisting in Jetpack Compose for the state ownership model.
Navigating back with a new argument
Use popBackStack() to return to the existing destination. Navigating to a new copy of the previous route leaves the current entry on the stack and can create repeated screens when the user presses Back.
Test argument passing
For each destination with arguments, verify:
- The route constructor rejects missing required values at compile time.
- The source creates the expected route instance when the user acts.
- The destination decodes the expected values with
toRoute<T>(). - Optional arguments work both when omitted and when populated.
- System Back returns to the original entry instead of creating a duplicate.
- Process recreation restores the route and the destination reloads its data.
- Malformed or unsupported custom values fail in a controlled, tested way.
The migration guide also documents hasRoute<T>(), which can be useful when a test or app shell needs to check the current destination without comparing raw route strings.
FAQ
What should I pass between Compose destinations?
Pass the minimum stable input the receiving destination needs—usually an ID, key, or small filter. Load complex or changing data from a repository or ViewModel.
How do I pass an optional argument?
Declare a nullable route property with a default value, such as val query: String? = null, then navigate with either Search() or Search(query = "compose").
Do I need to manually URL-encode route arguments?
Not when using the type-safe navigate(route) API. The Navigation library serializes the route instance and reconstructs it with toRoute<T>(). Do not reintroduce manual string interpolation around typed routes.
Can a ViewModel read a destination argument?
Yes. Use savedStateHandle.toRoute<YourRoute>() in the ViewModel, then use the decoded ID or filter to load screen data.