Skip to main content

State Patterns

State management is the central problem in UI engineering. Get it wrong and your app becomes a maze of mutually-inconsistent flags. Get it right and features become trivially testable, predictable, and refactorable.

The single-UiState pattern

A screen's render is a pure function of one immutable state object. No flags scattered across multiple Flows. No "if loading and not error and data not null". One class, one source of truth.

data class CatalogUiState(
val isLoading: Boolean = false,
val products: List<Product> = emptyList(),
val filter: CatalogFilter = CatalogFilter.All,
val error: String? = null,
val isRefreshing: Boolean = false
)

The Composable becomes a pure render:

@Composable
fun CatalogScreen(state: CatalogUiState, onIntent: (CatalogIntent) -> Unit) {
when {
state.isLoading && state.products.isEmpty() -> LoadingView()
state.error != null && state.products.isEmpty() -> ErrorView(state.error, onRetry = { onIntent(CatalogIntent.Refresh) })
else -> CatalogList(
products = state.products,
isRefreshing = state.isRefreshing,
onRefresh = { onIntent(CatalogIntent.Refresh) },
onProductClick = { onIntent(CatalogIntent.OpenProduct(it.id)) }
)
}
}

Reducer / MVI pattern

When state transitions get complex, model them with an Intent → Reducer → State loop. This is MVI (Model-View-Intent) — and it's exactly how Redux, Flux, and many Compose libraries work.

sealed interface CatalogIntent {
data object Refresh : CatalogIntent
data class OpenProduct(val id: String) : CatalogIntent
data class ChangeFilter(val filter: CatalogFilter) : CatalogIntent
data object Retry : CatalogIntent
}

@HiltViewModel
class CatalogViewModel @Inject constructor(
private val getCatalog: GetCatalogUseCase
) : ViewModel() {

private val _state = MutableStateFlow(CatalogUiState(isLoading = true))
val state: StateFlow<CatalogUiState> = _state.asStateFlow()

private val _events = MutableSharedFlow<CatalogEvent>()
val events: SharedFlow<CatalogEvent> = _events.asSharedFlow()

init { loadCatalog() }

fun onIntent(intent: CatalogIntent) {
when (intent) {
CatalogIntent.Refresh -> loadCatalog(showRefreshing = true)
is CatalogIntent.OpenProduct -> viewModelScope.launch { _events.emit(CatalogEvent.NavigateToDetail(intent.id)) }
is CatalogIntent.ChangeFilter-> _state.update { it.copy(filter = intent.filter) }
CatalogIntent.Retry -> loadCatalog()
}
}

private fun loadCatalog(showRefreshing: Boolean = false) = viewModelScope.launch {
_state.update {
if (showRefreshing) it.copy(isRefreshing = true, error = null)
else it.copy(isLoading = true, error = null)
}
runCatching { getCatalog() }
.onSuccess { products ->
_state.update { it.copy(isLoading = false, isRefreshing = false, products = products, error = null) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, isRefreshing = false, error = e.message) }
}
}
}

State vs Event — get this right

A piece of UI is state if it can be re-rendered correctly after rotation. An event is something that should fire once, regardless of rotation.

Looks likeState or Event?Why
A list of productsStateRe-renders identically
An error bannerStateShould remain visible after rotation
A snackbarEventShowing the same one twice on rotation is wrong
A navigation intentEventRe-navigating on rotation is wrong
A loading spinnerStateShould still spin if still loading after rotation
Vibration feedbackEventVibrating again is annoying
Selected tab indexStateSurvives rotation
sealed interface CatalogEvent {
data class NavigateToDetail(val productId: String) : CatalogEvent
data class ShowSnackbar(val message: String) : CatalogEvent
}

@Composable
fun CatalogRoute(viewModel: CatalogViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHost = remember { SnackbarHostState() }
val nav = LocalNavController.current

LaunchedEffect(viewModel) {
viewModel.events.collect { event ->
when (event) {
is CatalogEvent.NavigateToDetail -> nav.navigate(Detail(event.productId))
is CatalogEvent.ShowSnackbar -> snackbarHost.showSnackbar(event.message)
}
}
}

Scaffold(snackbarHost = { SnackbarHost(snackbarHost) }) { padding ->
CatalogScreen(state = state, onIntent = viewModel::onIntent, modifier = Modifier.padding(padding))
}
}

Choosing patterns: complexity vs benefit

Plain MVVM

Use when...

  • Screen has < 3 state branches
  • 1–2 user intents
  • No complex async sequences
  • Solo developer or small team
  • Most CRUD screens
MVI + Reducer

Use when...

  • State has 5+ fields with constraints
  • Many intents driving transitions
  • Complex orchestration (debounce, retry)
  • Multiple devs sharing the screen
  • Needs deterministic time-travel debugging

Side effects and the LaunchedEffect family

EffectWhen to use
LaunchedEffect(k)Run a coroutine when k changes; cancel on dispose
rememberCoroutineScopeLaunch from event handlers (button onClick)
DisposableEffectRun setup; provide an onDispose { } for cleanup
SideEffectRun after every successful recomposition (analytics, logging)
produceStateConvert non-Compose async source into a State<T>
derivedStateMemoize an expensive computation derived from other states
@Composable
fun ProductRow(product: Product) {
// SideEffect — fires on every successful recomposition
SideEffect { analytics.logVisible("product_row", product.id) }

// derivedStateOf — only recomposes downstream when result changes,
// even if other inputs change
val isOnSale by remember(product) {
derivedStateOf { product.discountPercent >= 10 }
}

Row { /* ... */ }
}

Practice

  1. 01

    Convert a stateful screen to MVI

    Pick a screen that uses multiple var booleans. Collapse them into a UiState data class and an Intent sealed interface.

  2. 02

    Audit your state vs events

    List every observable in your ViewModel. Mark each as state or event. Migrate misclassified ones.

  3. 03

    Use derivedStateOf

    Find a place you compute the same value on every recomposition. Wrap it in derivedStateOf and verify recompositions drop in Layout Inspector.