Dev Notes

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

  1. Hoist state as high as the lowest common ancestor of composables that use it.
  2. Never mutate state directly in composables — always go through event callbacks up to the ViewModel.
  3. Prefer StateFlow over LiveData in new code — it’s coroutine-native and Compose-friendly.
  4. Use collectAsStateWithLifecycle() (not collectAsState()) to respect the lifecycle.

Comments