alex-arguello.com_ menu ≡

NOTE · 2026-04-28 · architecture · 5 min

MVI over MVVM: why StateFlow + SharedFlow is the right shape for Android

With the transition to Jetpack Compose, UI performance is no longer about XML view hierarchies; it is a discipline of state management. Traditional MVVM, often built on fragmented LiveData streams or unstructured view models, routinely breaks Compose’s smart recomposition engine through unintended renders and state drift. To keep payment-flow code legible and predictable under load, the superapp-demo enforces a unidirectional Model-View-Intent (MVI) architecture.

UDF coroutine topology — Compose collects through a lifecycle-gated boundary, ViewModel exposes a StateFlow of UiState (continuous, last value held) and a SharedFlow of Event (one-shot, never replayed); user input flows back as Intent

Two coroutine shapes, not one

The data pipeline splits into two distinct Kotlin Coroutine shapes: StateFlow<UiState> and SharedFlow<Event>. They are not interchangeable, and the asymmetry is the entire point.

StateFlow exclusively drives the UI, exposing a single, immutable, @Stable data class that represents the entire screen — loading flags, items, error state, all of it. Compose collects it, conflates equal values automatically, and only recomposes when equals() returns false. The @Stable annotation is what tells Compose to trust that contract; without it, Compose falls back to defensive recomposition and the smart-skip optimization is lost.

SharedFlow handles volatile, “one-shot” events — a navigation handoff, a snackbar trigger, a haptic — actions that must happen exactly once and must not be replayed when the user rotates the device or returns from background. Replay semantics are explicit: replay = 0 means a new collector after a configuration change does not see the event that fired ten seconds ago. extraBufferCapacity = 1 keeps emit() non-suspending across the brief gap when no one is collecting.

Together they expose the screen as a single typed contract:

class ScreenViewModel : ViewModel() {
  private val _state = MutableStateFlow(UiState())
  val state: StateFlow<UiState> = _state.asStateFlow()

  private val _events = MutableSharedFlow<Event>(
    replay = 0,
    extraBufferCapacity = 1,
  )
  val events: SharedFlow<Event> = _events.asSharedFlow()

  fun onIntent(intent: Intent) {
    // mutate _state.value, emit() onto _events
  }
}

The lifecycle gate

By tying both streams to the lifecycle through collectAsStateWithLifecycle(), the UI layer stops collecting when the screen is not in STARTED. No coroutine leaks, no wasted recomposition while the user is in a different app, and no race conditions where a backgrounded screen briefly receives a state it cannot render. The boundary is one line of code per stream — and it makes a category of bugs structurally impossible.

Gotchas

Three things that look right and aren’t:

UiState not annotated @Stable. The data class will still emit and Compose will still render, but skippable composables recompose unnecessarily because the Compose compiler cannot prove stability without the contract.

Using MutableStateFlow for events. State is held; events are not. Hold an event in StateFlow and a configuration change replays it — your snackbar shows twice.

Collecting events with LaunchedEffect(Unit) instead of repeatOnLifecycle(Lifecycle.State.STARTED). LaunchedEffect keys on composition, not lifecycle, so it keeps collecting through onStop(). Rare on the main path; common in modal screens.

When not to reach for MVI

Single-screen utilities (a settings toggle, a debug switch) and prototypes do not pay back the structural overhead. Hybrid screens partially driven by external state — a MapView, a third-party SDK that owns its own lifecycle — get contorted under the unidirectional contract. Reach for MVI when the screen has more than two pieces of asynchronous state and the consequences of state drift are real.

← ./notes · cd ../