← Coding round practice

Shopping Cart Screen with Optimistic Updates

MediumState · Error handlingInstant UI, reconciled with the server

The problem

Build a cart screen: a list of items with quantity steppers, a live total, remove-with-undo, and a checkout button.

Requirements:

  • Tapping + or − updates the UI instantly, before the API confirms.
  • If the API fails, roll back and tell the user.
  • Rapid taps must not fire one request per tap.
  • Checkout must be impossible while any update is in flight.
Stuck? Open one hint

Keep the last known-good state before you mutate. Rollback is just putting it back.

Show the worked solution

This is a state-modelling exercise disguised as a cart.

Optimistic update: change the state immediately, remember the previous value, call the API, and on failure restore the previous value plus a message. The UI never waits for the network.

Rapid taps: debounce per item id, or keep a per-item job and cancel the previous one, so ten taps send one request with the final quantity.

Totals: compute them from the item list in the state, never store them separately — two sources of truth for the total is the bug they are hoping to see.

Undo: remove from the list but keep the removed item with its index for a few seconds; the snackbar action puts it back. Only commit the delete to the server after the window passes.

Checkout guard: derive canCheckout = items.isNotEmpty() && pendingUpdates == 0 from the state instead of toggling a separate boolean.

KotlinInstant UI, reconciled with the server
data class CartState(
    val items: List<CartItem> = emptyList(),
    val pending: Set<String> = emptySet(),
    val message: String? = null,
) {
    val total: Int get() = items.sumOf { it.price * it.quantity }   // derived, never stored
    val canCheckout: Boolean get() = items.isNotEmpty() && pending.isEmpty()
}

class CartViewModel(private val repo: CartRepository) : ViewModel() {
    private val _state = MutableStateFlow(CartState())
    val state = _state.asStateFlow()

    private val jobs = mutableMapOf<String, Job>()

    fun setQuantity(id: String, quantity: Int) {
        val before = _state.value.items                               // keep the good state
        _state.update { s ->
            s.copy(
                items = s.items.map { if (it.id == id) it.copy(quantity = quantity) else it },
                pending = s.pending + id,
            )
        }

        jobs[id]?.cancel()                                            // collapse rapid taps
        jobs[id] = viewModelScope.launch {
            delay(400)
            runCatching { repo.updateQuantity(id, quantity) }
                .onFailure {
                    _state.update { it.copy(items = before, message = "Could not update quantity") }
                }
            _state.update { it.copy(pending = it.pending - id) }
        }
    }
}