Unidirectional data flow (UDF) fixes the direction of every change. State flows down from the ViewModel to the composables; events flow up from the composables to the ViewModel; only the ViewModel changes state, so there is one place to look when something is wrong. For the cart, the events are a sealed interface and the ViewModel has one entry point:
sealed interface CartEvent {
data class Toggle(val book: Book) : CartEvent
data class Remove(val book: Book) : CartEvent
}
class CartViewModel(private val saved: SavedStateHandle) : ViewModel() {
val cart: StateFlow<List<Int>> = saved.getStateFlow("cart", emptyList()) // survives death
...
fun onEvent(event: CartEvent) {
val ids = cart.value
val (newIds, message) = when (event) {
is CartEvent.Toggle ->
if (event.book.id in ids) ids - event.book.id to "Removed ${event.book.title}"
else ids + event.book.id to "Added ${event.book.title}"
is CartEvent.Remove -> ids - event.book.id to "Removed ${event.book.title}"
}
saved["cart"] = ArrayList(newIds) // must be Bundle-compatible
_messages.trySend(message)
}
}
In the composables, events become lambdas: the detail screen receives onToggleCart = { cartVm.onEvent( CartEvent.Toggle(book)) } and never sees the ViewModel. The ViewModel is testable without UI: call onEvent, check cart.value (Testing). The cart lives in a SavedStateHandle, the ViewModel's view of the saved-state Bundle, so it also survives process death.