Jetpack Compose Architecture Patterns
Compose changed how I think about UI state. Three years into building with it in production, a handful of patterns have proven themselves over and over — and a few "obvious" approaches have quietly caused most of my bugs. Here's what actually holds up.
Unidirectional Data Flow (UDF) is the foundation, not a pattern
Everything below assumes this shape:
Event (UI) → ViewModel → State (StateFlow) → UI (recompose)
State flows down, events flow up. No two-way binding, no Composable mutating shared state directly. If you're passing a MutableState down five levels so a child can flip it, you've already broken UDF — pass a lambda instead.
MVVM + Clean Architecture (the default choice)
Still the most common setup for a reason:
- ViewModel exposes a single
StateFlow<UiState>(sealed class or data class), collected viacollectAsStateWithLifecycle(). - UseCases/Interactors sit between ViewModel and Repository — worth it once you have more than two ViewModels sharing logic.
- Repository abstracts Room/Retrofit/DataStore behind an interface.
The mistake I still see constantly: exposing multiple StateFlows instead of one consolidated UI state. It looks convenient until two flows update out of sync and the UI renders a half-updated frame. One state class, one source of truth, always.
MVI — when state transitions get genuinely complex
MVI (Model-View-Intent) adds an explicit Intent/Action sealed class and routes everything through a reducer:
Intent → reduce(currentState, intent) → newState
Worth the extra boilerplate when a screen has real state machines — checkout flows, multi-step forms, anything with "you can't do X while Y is loading" rules. Overkill for a simple list screen.
State hoisting: the actual unit of reusability
Compose's real architectural primitive isn't the ViewModel — it's hoisted state. A Composable that takes value and onValueChange instead of owning a remember { mutableStateOf() } is testable, previewable, and reusable outside your ViewModel entirely. Push state up until it hits the layer that owns the source of truth (ViewModel, SavedStateHandle, or nothing at all for pure UI state like an expanded/collapsed toggle).
Layered UI state: don't let "loading" become a boolean
A common trap:
data class UiState(val isLoading: Boolean, val data: List<Item>?, val error: String?)
This allows impossible states — loading and error and data all true at once. Prefer a sealed hierarchy:
sealed interface UiState {
data object Loading : UiState
data class Success(val items: List<Item>) : UiState
data class Error(val message: String) : UiState
}
Now the when in your Composable is exhaustive, and impossible states literally can't compile.
Navigation: keep it out of the ViewModel
ViewModels shouldn't know about NavController. Emit one-shot navigation events (a Channel or SharedFlow) and let the Composable/NavHost act on them. It keeps ViewModels testable without a fake navigation stack.
Quick take
| Pattern | Use when |
|---|---|
| MVVM + Clean | Default for most screens |
| MVI | Complex multi-step state machines |
| State hoisting | Every reusable Composable, always |
| Sealed UI state | Any screen with loading/error/success |
The real architectural discipline in Compose isn't picking MVVM vs MVI — it's being ruthless about single-source-of-truth state and never letting a Composable own state it doesn't need to.
