State Management in Jetpack Compose
State management in Compose is built around a clear principle: UI is a function of state. Understanding this model is the key to writing predictable, testable Compose UIs.
The core idea
// State lives in ViewModel, flows down to UI
@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
CounterContent(
count = uiState.count,
onIncrement = viewModel::increment,
onDecrement = viewModel::decrement,
)
}
Notice that CounterContent is a pure function: given the same inputs it always renders the same output. All side effects go through the ViewModel.
ViewModel with StateFlow
data class CounterUiState(val count: Int = 0)
class CounterViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CounterUiState())
val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()
fun increment() {
_uiState.update { it.copy(count = it.count + 1) }
}
fun decrement() {
_uiState.update { it.copy(count = it.count - 1) }
}
}
Using MutableStateFlow with a sealed UiState data class is the idiomatic pattern. .update {} ensures thread-safe atomic updates.
Derived state
When you need state computed from other state, use derivedStateOf to avoid unnecessary recompositions:
@Composable
fun ExpensiveList(items: List<String>, query: String) {
val filtered by remember(items) {
derivedStateOf { items.filter { it.contains(query, ignoreCase = true) } }
}
LazyColumn {
items(filtered) { Text(it) }
}
}
Key rules
- Hoist state as high as the lowest common ancestor of composables that use it.
- Never mutate state directly in composables — always go through event callbacks up to the ViewModel.
- Prefer
StateFlowoverLiveDatain new code — it’s coroutine-native and Compose-friendly. - Use
collectAsStateWithLifecycle()(notcollectAsState()) to respect the lifecycle.