Selection Is Not Navigation in Jetpack Compose
Product lists are everywhere.
The UI shows a few fields: name, description, price. The domain model has much more. When the user taps a row, the app opens detail.
That sounds simple, but the design questions arrive quickly:
- Should the row expose the domain
ProductId? - Should the UI state contain an ID?
- Should the ViewModel keep a map from row ID to
Product? - Should the composable call
viewModel.onProductSelected(id)? - Should the ViewModel call
NavController?
There is not one answer for every app.
But there is a useful distinction:
Selection is not navigation.
A row can report selection without deciding what selection means.
#Passing IDs Is Useful, But Not Universal
If product detail should load fresh data, restore from process death, support deep links, or fetch fields the list never loaded, route with an ID.
navController.navigate("products/${productId.value}")
Then the detail screen can load what it needs:
class ProductDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val loadProductDetails: LoadProductDetailsUseCase
) : ViewModel() {
private val productId = ProductId(
requireNotNull(savedStateHandle["productId"])
)
fun load() {
viewModelScope.launch {
loadProductDetails.execute(productId)
}
}
}
That is a strong boundary when the detail feature is truly identified by product ID.
But sometimes the list already has the full Product, and the row only renders part of it. Detail is a continuation of the selected product, not a fresh lookup.
If there is no "get product by ID" use case, do not invent one only to satisfy a navigation habit.
#Keep UI State About Rendering
This UI state is pure render data:
data class ProductUiState(
val title: String,
val subtitle: String,
val price: String
)
It does not expose ProductId. It does not contain a click lambda. It does not know about navigation.
Could you add an ID?
Yes.
data class ProductUiState(
val id: ProductId,
val title: String,
val subtitle: String,
val price: String
)
That can be fine if identity is naturally part of the UI state.
But if the ID exists only because something else wants to navigate later, the UI state is carrying a concern that does not belong to rendering.
#Wrap Render State In A Selectable Row
Selection adds identity and behavior.
Make that explicit:
class SelectableProductRow(
val id: String,
val product: ProductUiState,
val onSelection: () -> Unit
)
This type is intentionally not a data class.
ProductUiState is pure data, so value equality is meaningful.
SelectableProductRow contains behavior. Function equality is not a useful domain concept, and Kotlin data classes would include the lambda in generated equality.
That is not what we want.
So the split is:
ProductUiStateis render dataSelectableProductRowis list identity plus render data plus selection behaviorid: Stringis stable UI identity forLazyColumnonSelectionreports that this row was selected
#Mapping Products Into Selectable Rows
The ViewModel can map full domain products into selectable rows without caching a product dictionary:
class ProductsViewModel(
private val onProductSelected: (Product) -> Unit
) : ViewModel() {
private val _rows = MutableStateFlow<List<SelectableProductRow>>(emptyList())
val rows: StateFlow<List<SelectableProductRow>> = _rows.asStateFlow()
fun didLoadProducts(products: List<Product>) {
_rows.value = products.map { product ->
SelectableProductRow(
id = product.id.value,
product = ProductUiState(
title = product.name,
subtitle = product.shortDescription,
price = product.price.format()
),
onSelection = {
onProductSelected(product)
}
)
}
}
}
The selected domain product is captured by the row's selection lambda.
There is no need for:
private val productsByRowId = mutableMapOf<String, Product>()
There is also no need for the composable to know about a ViewModel method such as didSelectRow(id).
The row already knows how to report its own selection upward.
#The Composable Stays Boring
The list renders selectable rows:
@Composable
fun ProductsListScreen(
rows: List<SelectableProductRow>
) {
LazyColumn {
items(
items = rows,
key = { row -> row.id }
) { row ->
ProductRow(
product = row.product,
modifier = Modifier.clickable(
onClick = row.onSelection
)
)
}
}
}
The row receives pure UI state:
@Composable
fun ProductRow(
product: ProductUiState,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Text(product.title)
Text(product.subtitle)
Text(product.price)
}
}
The composable does not know whether selection opens detail, adds to cart, starts checkout, or sends analytics.
It renders a selectable row.
That is enough.
#The Parent Decides What Selection Means
The selection callback is formed outside the list:
@Composable
fun ProductsRoot(
productsViewModel: ProductsViewModel
) {
val rows by productsViewModel.rows.collectAsStateWithLifecycle()
ProductsListScreen(rows = rows)
}
The parent or factory creates the ViewModel with a selection callback:
val viewModel = ProductsViewModel(
onProductSelected = { product ->
navController.navigate("products/${product.id.value}")
}
)
If your detail is a continuation of the already-loaded product, the parent flow can pass the selected product to a detail factory or feature owner instead of navigating by ID:
val viewModel = ProductsViewModel(
onProductSelected = { product ->
selectedProduct = product
}
)
Then the parent renders detail from that selected product.
The list still does not know what happened.
#What About NavController In The ViewModel?
I would avoid this in most features:
class ProductsViewModel(
private val navController: NavController
) : ViewModel() {
fun didSelect(product: Product) {
navController.navigate("products/${product.id.value}")
}
}
Now the ViewModel knows that product selection means navigation. It knows the route shape. It becomes harder to reuse the same list when selection should mean "add to cart" or "compare products."
The selectable row approach keeps the ViewModel closer to presentation mapping:
Product -> SelectableProductRow -> row.onSelection -> parent flow
The parent owns the journey.
#Testing Render Data And Selection Separately
ProductUiState can be a data class because it is pure render data.
That makes mapping tests simple:
@Test
fun `maps products into rows`() {
val viewModel = ProductsViewModel(
onProductSelected = {}
)
viewModel.didLoadProducts(listOf(Product.keyboard))
assertEquals(
listOf("keyboard"),
viewModel.rows.value.map { it.id }
)
assertEquals(
listOf(
ProductUiState(
title = "Keyboard",
subtitle = "Mechanical",
price = "$120"
)
),
viewModel.rows.value.map { it.product }
)
}
Do not make SelectableProductRow a data class just to compare the whole row.
Test selection as behavior:
@Test
fun `selecting row reports selected product`() {
var selectedProduct: Product? = null
val viewModel = ProductsViewModel(
onProductSelected = { selectedProduct = it }
)
viewModel.didLoadProducts(listOf(Product.keyboard))
viewModel.rows.value[0].onSelection()
assertEquals(Product.keyboard, selectedProduct)
}
That split is honest:
- render data is compared as data
- row selection is tested by invoking the behavior
#When A Lookup Is Better
Capturing the product in the row lambda is not always the best choice.
Use a lookup by row ID when:
- rows can update independently from the domain model
- selection must use the latest product, not the captured product
- products are too large or sensitive to retain in row closures
- selection behavior should be centralized for analytics, permissions, or validation
- list items represent something more complex than one product
Then a ViewModel method can be reasonable:
fun didSelectRow(id: String) {
val product = productsByRowId[id] ?: return
onProductSelected(product)
}
That is a tradeoff.
The important part is to choose it because the feature needs it, not because every selection must become an ID lookup.
#What You Should Remember
Selection is not navigation.
ProductUiState should describe what the row renders.
SelectableProductRow can add stable list identity and selection behavior without making the composable know about domain models or navigation.
Route with an ID when detail should reload, restore, or deep link. Pass the selected product when detail is a continuation of already-loaded data.
The composable renders rows. The ViewModel maps products into selectable rows. The parent flow decides what selected product means.