NOTE · 2026-04-28 · architecture · 5 min
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.
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
}
}
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.
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.
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.