State Hoisting & Unidirectional Data Flow
The problem with state inside a component
If a composable holds its own state, it can’t be controlled or tested from outside, and two copies can’t share data. The solution is state hoisting: move the state up to the caller, and pass the value down with an event (lambda) to change it.
Stateful vs stateless
// Stateless: gets its value and reports events — reusable & testable
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("Count: $count")
}
}
// Stateful: owns the state, passes it down
@Composable
fun CounterScreen() {
var count by rememberSaveable { mutableStateOf(0) }
Counter(count = count, onIncrement = { count++ })
}
The reusable Counter knows nothing about where the count comes from. The same component could be driven by a ViewModel, a parent, or a test.
Unidirectional Data Flow (UDF)
This pattern has a name: state flows down, events flow up. The UI displays state; user actions send events upward to whoever owns the state, which updates it, which flows back down. It is predictable and easy to debug.
Where should state live?
- Keep it at the lowest common owner of everything that needs it.
- If only one composable uses it, keep it there.
- If it must survive navigation or rotation, or involves business logic, hoist it to a ViewModel.
Common mistakes
- Hoisting state too high so everything recomposes unnecessarily.
- Making components stateful when they should just receive a value and a callback.
- Mutating shared state from deep inside the tree instead of sending an event up.
Summary: Hoist state to the caller and follow UDF — state flows down, events flow up. Stateless composables are reusable, testable and predictable.