Architecture With Nico

Practical software architecture and design for iOS engineers.

Selection Is Not Navigation in SwiftUI

List selection looks simple until the architecture questions start.

You have a products list. Each row shows a title, a subtitle, and a price. When the user taps a row, the app should show product detail.

What should the row expose?

Should the view pass Product.ID?

Should it pass the whole Product?

Should the row UI state contain the ID?

Should the ViewModel keep a dictionary from row ID to domain model?

This is one of those small UI problems that reveals how you think about boundaries.

#The Default Answer Is Not Always "Pass The ID"

Passing an ID is often a good choice.

If the detail screen should load fresh data, support deep links, restore navigation state, or show data that may not be in the list, an ID is a strong route value:

enum ProductsRoute: Hashable {
    case detail(Product.ID)
}

Then the detail feature can load what it needs:

func makeProductDetailView(productID: Product.ID) -> ProductDetailView {
    let viewModel = ProductDetailViewModel()
    let useCase = LoadProductDetailsUseCase(
        productID: productID,
        repository: productsRepository,
        output: viewModel
    )

    return ProductDetailView(
        viewModel: viewModel,
        onAppear: { await useCase.execute() }
    )
}

That works well when there is a real "load product by ID" capability.

But sometimes you do not have that.

Sometimes the list already loaded the full Product, but the row only renders part of it. The detail screen is not fetching a different product. It is continuing from the product the user selected.

In that case, forcing everything through an ID can add ceremony without buying much.

#Do Not Add IDs To UI State Just For Navigation

This is the part I would be careful with:

struct ProductUIState: Identifiable {
    let id: Product.ID
    let title: String
    let subtitle: String
    let price: String
}

This can be fine if the UI state naturally has identity.

But if the ID is only there because navigation needs something later, the UI state is carrying routing baggage.

The row's render state should describe what the row renders:

struct ProductUIState: Equatable {
    let title: String
    let subtitle: String
    let price: String
}

No domain ID. No selection callback. No navigation concern.

Just renderable state.

#A Selectable Row Is More Than State

Selection adds behavior.

Instead of pretending that behavior is part of pure UI state, create a separate type:

struct SelectableProductRow: Identifiable {
    let id: String
    let product: ProductUIState
    let onSelection: () -> Void
}

This tells the truth:

  • ProductUIState is render data.
  • SelectableProductRow is a list item with identity and behavior.
  • id is stable list identity for SwiftUI, not a domain type exposed to the view.
  • onSelection is what happens when the row is selected.

The row is not Equatable, and that is intentional. It contains a closure. Closures do not have meaningful value equality.

#Mapping Domain Models Into Selectable Rows

The ViewModel can receive full domain products and expose selectable rows:

@MainActor
final class ProductsViewModel: ObservableObject {
    @Published private(set) var rows: [SelectableProductRow] = []

    private let onProductSelected: (Product) -> Void

    init(onProductSelected: @escaping (Product) -> Void) {
        self.onProductSelected = onProductSelected
    }

    func didLoadProducts(_ products: [Product]) {
        rows = products.map { product in
            SelectableProductRow(
                id: product.id.rawValue,
                product: ProductUIState(
                    title: product.name,
                    subtitle: product.shortDescription,
                    price: product.price.formatted()
                ),
                onSelection: { [onProductSelected] in
                    onProductSelected(product)
                }
            )
        }
    }
}

There is no dictionary from row ID to product.

There is no need for the view to call viewModel.didSelectRow(id:).

The selected Product is captured by the row's selection closure at mapping time.

That is a reasonable tradeoff when the list already has the full domain model and detail is a continuation of that selection.

#The View Stays Simple

The list only renders selectable rows:

struct ProductsListView: View {
    let rows: [SelectableProductRow]

    var body: some View {
        List(rows) { row in
            Button(action: row.onSelection) {
                ProductRowView(product: row.product)
            }
        }
    }
}

The row view receives pure UI state:

struct ProductRowView: View {
    let product: ProductUIState

    var body: some View {
        VStack(alignment: .leading) {
            Text(product.title)
            Text(product.subtitle)
            Text(product.price)
        }
    }
}

The view does not know whether selection pushes detail, opens a sheet, starts checkout, adds to cart, or logs analytics.

It only knows the row is selectable.

#The Flow Decides What Selection Means

The callback is formed at the composition boundary:

struct ProductsRootView: View {
    @State private var path: [ProductsRoute] = []
    let factory: ProductsFeatureFactory

    var body: some View {
        NavigationStack(path: $path) {
            factory.makeProductsListView(
                onProductSelected: { product in
                    path.append(.detail(product))
                }
            )
            .navigationDestination(for: ProductsRoute.self) { route in
                switch route {
                case let .detail(product):
                    factory.makeProductDetailView(product: product)
                }
            }
        }
    }
}

enum ProductsRoute: Hashable {
    case detail(Product)
}

This example passes the selected product because the detail screen continues from the already-loaded product.

For NavigationStack, route values need to be Hashable. If your Product is not a good route value, do not force the domain model to conform just for navigation. Use another flow-owned mechanism, such as selected detail state, or route with an ID when that better represents the feature.

If your detail feature should reload, restore, or deep link, route with an ID instead:

enum ProductsRoute: Hashable {
    case detail(Product.ID)
}

The point is not "always pass product" or "always pass ID."

The point is to choose based on the feature's real boundary.

#Where The Factory Fits

The factory wires the ViewModel to the selection callback:

struct ProductsListRoute: View {
    @ObservedObject var viewModel: ProductsViewModel
    let onAppear: () async -> Void

    var body: some View {
        ProductsListView(rows: viewModel.rows)
            .task {
                await onAppear()
            }
    }
}

struct ProductsFeatureFactory {
    func makeProductsListView(
        onProductSelected: @escaping (Product) -> Void
    ) -> ProductsListRoute {
        let viewModel = ProductsViewModel(
            onProductSelected: onProductSelected
        )

        let useCase = LoadProductsUseCase(
            repository: productsRepository,
            output: viewModel
        )

        return ProductsListRoute(
            viewModel: viewModel,
            onAppear: useCase.execute
        )
    }
}

The important part is that the factory wires the callback. It does not decide navigation itself.

The flow decides what selected product means.

This example uses @ObservedObject because the ViewModel is injected into the route. If the route creates the ViewModel and owns its lifetime, use @StateObject instead. The key is to be honest about ownership: @ObservedObject observes an object owned elsewhere; @StateObject owns an object created by that view.

One practical warning: do not recreate this object graph from a frequently recomputed body if the ViewModel should keep state. Create it from a stable parent, flow, or composition boundary.

#Testing Render Data And Selection Separately

ProductUIState can be Equatable because it is pure data.

That makes render-state tests pleasant:

func test_didLoadProducts_mapsRows() {
    let viewModel = ProductsViewModel(
        onProductSelected: { _ in }
    )

    viewModel.didLoadProducts([.keyboard])

    XCTAssertEqual(viewModel.rows.map(\.id), ["keyboard"])
    XCTAssertEqual(
        viewModel.rows.map(\.product),
        [
            ProductUIState(
                title: "Keyboard",
                subtitle: "Mechanical",
                price: "$120"
            )
        ]
    )
}

SelectableProductRow should not be Equatable, because it contains behavior.

Test the behavior by invoking it:

func test_selectingRowReportsSelectedProduct() {
    var selectedProduct: Product?
    let viewModel = ProductsViewModel(
        onProductSelected: { selectedProduct = $0 }
    )

    viewModel.didLoadProducts([.keyboard])
    viewModel.rows[0].onSelection()

    XCTAssertEqual(selectedProduct, .keyboard)
}

That split is honest:

  • render data is compared as data
  • selection behavior is tested as behavior

#When I Would Use A Lookup Instead

The selectable-row closure is not the only good shape.

I would use a row ID lookup when:

  • rows are edited, reordered, or updated independently
  • selection behavior needs to read the latest product, not the product captured when the row was built
  • retaining the selected domain model in a closure would be too expensive
  • the row action needs to be rebuilt less often than the row state

Then this can be better:

func didSelectRow(id: String) {
    guard let product = productsByRowID[id] else { return }
    onProductSelected(product)
}

But do not add the lookup only because it feels more architectural.

If the list already maps full products into rows, and selection simply reports the selected product upward, the closure-based selectable row is clear and direct.

#What You Should Remember

Selection is not navigation.

A list row can report that it was selected without knowing what selection means.

Keep pure UI state focused on what the row renders. Wrap it in a selectable row when you need identity and behavior.

Use Product.ID for navigation when detail should reload, restore, or deep link. Pass Product when detail is a continuation of already-loaded data.

The view renders selectable rows. The ViewModel maps products into rows. The flow decides what selected product means.