DEV Community

Cover image for I Thought Passing Data Between Screens Would Be Easy. It Was Not.
Aalaa Fahiem
Aalaa Fahiem

Posted on

I Thought Passing Data Between Screens Would Be Easy. It Was Not.

Let me tell you exactly how my week went.
Day one: I tried to pass a full Product object through navigation instead of just the ID.
The app compiled fine. Then it crashed the moment I tapped a product. No helpful message. Just gone.
Day two: I passed the ID correctly this time — I'd learned from the last article — but after navigating to the detail screen, I had this weird feeling: where is the data actually living right now? I couldn't explain it. I just trusted that backStackEntry.arguments would have it, and sometimes it did, and sometimes I got null, and I had no idea why.
Day three: I figured out how to send data forward fine. But then I needed to send data back — back to the previous screen after the user made a selection. And I stared at the screen for a very long time because nothing in what I'd learned prepared me for that direction.
All three of these happened to me within the same week on the same project. And they all came from the same root problem: I was guessing at how navigation handles data instead of actually understanding it.
This article is me stopping the guessing — for both of us.
We're continuing with the e-commerce app from the last article: Product List → Product Detail → Cart. If you haven't read that one, the short version is: we have a sealed class for routes, a NavHost, and a NavController. That setup stays exactly the same. What we're doing today is understanding everything that happens when data moves between those screens.

Why You Can't Just Pass a Full Object
This is where most people start — and where most people hit their first wall.
You have a Product. The detail screen needs a Product. Why not just... pass it?
kotlin// This feels logical. Don't do it.
navController.navigate(Screen.ProductDetail.createRoute(product))
Here's why it doesn't work.
Navigation routes are strings. That's the core of the system. When you call navController.navigate(...), you're navigating to a string address — like a URL. "product_detail/1" is a valid address. "product_detail/Product(id=1, name=Air Max 90, price=120.0)" is not.
The system wasn't built to serialize and deserialize objects through a URL. And even if you found a workaround — JSON encoding, for example — you'd be fighting the architecture instead of using it.
There's also a deeper reason. Screens in a navigation stack can be recreated from their route alone. If Android kills your process and the user comes back, it needs to be able to rebuild the back stack. A string ID can do that. A full object floating in memory cannot.
So the rule is simple: pass the minimum. Pass the ID.
kotlin// This is the right mental model.
// The ID is just an address. The screen goes and fetches the full data.
navController.navigate(Screen.ProductDetail.createRoute(product.id))

Primitives: What's Actually Happening
In the last article, we passed a productId: Int and moved on. Let's slow down and actually read what's happening, because this is where the null mystery comes from.
Here's the route definition:
kotlinobject ProductDetail : Screen("product_detail/{productId}") {
fun createRoute(productId: Int) = "product_detail/$productId"
}
The {productId} in the route template is a placeholder. It's saying: "when this screen is registered, expect something called productId to be embedded in the URL."
When you navigate with "product_detail/1", the system parses the URL, matches the pattern, and extracts 1 as the value of productId. It holds onto it in a Bundle called arguments.
That Bundle lives inside the NavBackStackEntry — the entry for this specific screen in the back stack. It's tied to that entry's lifecycle. As long as the screen is in the back stack, the arguments are there.
Here's the NavHost registration:
kotlincomposable(
route = Screen.ProductDetail.route,
arguments = listOf(
navArgument("productId") { type = NavType.IntType }
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getInt("productId") ?: 0
ProductDetailScreen(productId = productId, navController = navController)
}
The navArgument block isn't just decoration. It tells the system the type of the argument. This matters because the URL is a string — "product_detail/1" — and the system needs to know to convert "1" into an Int, not leave it as "1".
If you forget the arguments list, or name the argument differently here than in the route template, the system can't match them. The value doesn't get extracted. backStackEntry.arguments?.getInt("productId") returns null. That's the mystery.
The ?: 0 at the end is a fallback — if for any reason the argument is missing, we default to 0 instead of crashing. In a real app, you'd probably show an error state instead, but the pattern is the same.

Optional Arguments and Default Values
Here's something the documentation doesn't make obvious: arguments can be optional.
By default, a {placeholder} in a route is required. If it's not there, navigation fails. But sometimes you have a screen that works fine without certain data — a search screen that can open with or without an initial query, for example.
In our e-commerce app, let's say the Cart screen can optionally receive a couponCode that pre-fills a discount field:
kotlinobject Cart : Screen("cart?couponCode={couponCode}") {
fun createRoute(couponCode: String? = null): String {
return if (couponCode != null) "cart?couponCode=$couponCode"
else "cart"
}
}
Two things changed here. First, the syntax: optional arguments use query parameter format — ?key={value} — instead of path format — /{value}. This is how the system knows an argument is optional.
Second, createRoute() now has two forms: one with a coupon code, one without. Both navigate to "the cart screen" — just with different context.
The NavHost registration:
kotlincomposable(
route = Screen.Cart.route,
arguments = listOf(
navArgument("couponCode") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val couponCode = backStackEntry.arguments?.getString("couponCode")
CartScreen(couponCode = couponCode, navController = navController)
}
The nullable = true and defaultValue = null are what make this argument optional. Without them, the system would require the argument to be present.
Now you can navigate to the cart two ways:
kotlin// From ProductDetail — no coupon
navController.navigate(Screen.Cart.createRoute())

// From a promotional banner — with a coupon pre-filled
navController.navigate(Screen.Cart.createRoute(couponCode = "SAVE20"))
Both work. The cart screen handles both cases based on whether couponCode is null or not.

Top comments (0)