Back to Blog
AndroidKotlinJetpack ComposeArchitecture

Jetpack Compose Architecture Patterns

July 30, 2026

Share
Jetpack Compose Architecture Patterns

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 via collectAsStateWithLifecycle().
  • 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

PatternUse when
MVVM + CleanDefault for most screens
MVIComplex multi-step state machines
State hoistingEvery reusable Composable, always
Sealed UI stateAny 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.

Share