Shopping Cart Screen with Optimistic Updates
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.
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) }
}
}
}