Architecture With Nico

Practical software architecture and design for iOS engineers.

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:

  • ProductUiState is render data
  • SelectableProductRow is list identity plus render data plus selection behavior
  • id: String is stable UI identity for LazyColumn
  • onSelection reports 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.