Jetpack Compose: state hoisting and unidirectional data flow
In Jetpack Compose, state flows down and events flow up (unidirectional data flow, UDF). Hoist state out of composables to the lowest common owner that needs it — or to a ViewModel for screen-level state — and keep individual composables stateless where practical. This is the most stable consensus in modern Android UI and the foundation every other Compose decision rests on.
Hoist state
State hoisting moves state up to a caller, making a composable stateless. A hoisted state-holder pattern replaces a state value with a value parameter (flows down) and an onValueChange lambda (flows up).
- A reusable composable SHOULD be stateless: it takes its state via parameters and emits changes via callback lambdas. This advances
separation-of-concerns— rendering is decoupled from state ownership. - State MUST be hoisted to the lowest common ancestor that reads or writes it — no higher. Hoisting too high causes unnecessary recomposition and couples unrelated subtrees; hoisting too low blocks sharing.
- Screen-level / business state (data loaded from repositories, navigation results, form submission status) SHOULD live in a
ViewModel, not in composition, so it survives configuration changes and process-death restoration. - Pure UI element state (scroll position, expanded/collapsed, focus, text-field cursor) MAY stay in composition via
rememberwhen no other component needs it.
// Stateless: state down, events up
@Composable
fun NameField(name: String, onNameChange: (String) -> Unit) {
OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
}
remember vs rememberSaveable
| API | Survives recomposition | Survives config change / process death | Use for |
|---|---|---|---|
remember { ... } |
Yes | No | Transient UI state recomputable on the spot |
rememberSaveable { ... } |
Yes | Yes (via saved-instance Bundle) |
UI state a user would be annoyed to lose on rotation |
- Use
rememberSaveablefor state that SHOULD survive rotation or process death (entered text, selected tab) when it does not belong in aViewModel. - Values stored in
rememberSaveableMUST beBundle-serializable or supplied with a customSaver. rememberMUST NOT be relied on across configuration changes — it is cleared when the composable leaves composition.
Expose immutable UI state
- A
ViewModelMUST expose read-only state — aStateFlow<UiState>(orState<UiState>), never the mutable backing field. Keep theMutableStateFlowprivate and expose the immutable upcast. - Composables MUST NOT mutate hoisted state directly; they signal intent through event callbacks. This is
explicit-over-implicit— every state change has a named, traceable entry point. - Collect
StateFlowwithcollectAsStateWithLifecycle()(fromlifecycle-runtime-compose) so collection stops in the background — prefer it overcollectAsState()on Android. Confirm the lifecycle-compose dependency is present. - Model screen state as a single immutable
data classor asealed interfaceof cases (Loading / Success / Empty / Error). Immutable state aligns withimmutability-by-defaultand removes a class of concurrency bugs.
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
}
// In the composable:
val state by viewModel.uiState.collectAsStateWithLifecycle()
Recomposition and stability awareness
- Pass the narrowest parameters a composable needs (e.g.
title: String), not whole aggregate objects, so recomposition is scoped to what actually changed. - Prefer stable types as parameters: immutable
data classes, primitives, and lambdas. Unstable types (e.g.varfields, plainListwhose runtime impl Compose can't prove immutable) can defeat recomposition skipping. - Use Kotlin immutable collections (
kotlinx.collections.immutable) or annotate types as@Immutable/@Stableonly when the contract genuinely holds — a false stability annotation causes missed updates. - Treat unnecessary recomposition as a performance concern, not a correctness one: make it work and right first, then measure with the Compose recomposition tooling before optimizing (
make-it-work-make-it-right-make-it-fast). - The Compose compiler enables strong skipping by default in current releases (Kotlin 2.x + the Compose compiler Gradle plugin), which skips composables even with some unstable parameters — but minimizing scope and preferring stable types remains the durable practice.