Unidirectional Data Flow

Unidirectional Data Flow: Events Up, State Down

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:

ui/ViewModels.kt: the cart's events and its single entry point (excerpt)Kotlin
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)
  }
}
The ViewModel-backed catalog filtered by a search, and the detail screen after Add to cart, with the one-time snackbar
The ViewModel-backed catalog filtered by a search, and the detail screen after Add to cart, with the one-time snackbar

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.