Explain state hoisting and unidirectional data flow in Compose
Compose has no two-way data binding: state flows down as parameters, events flow up as lambdas, and hoisting moves state to where both sides meet.
LEARN BY DOINGPut your intuition to work3 steps
Explain state hoisting and unidirectional data flow in Compose
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The core rule: state flows down, events flow up
Compose has no two-way data binding and no observable model objects that a view silently subscribes to and mutates. Instead, it leans on a single discipline called unidirectional data flow (UDF): state is passed down through the composable tree as plain parameters, and anything the user does is passed back up as calls to lambda parameters — events, not mutations. A composable that follows this rule never reaches into its own parameters to change them; it only reads what it was given and reports what happened.
State hoisting is the mechanical technique that makes a composable follow UDF: instead of a composable owning a private remember { mutableStateOf(...) } }, you move ("hoist") that state to a caller, and the composable receives two things instead — the current value, and a callback to request a change. The canonical shape is value: T paired with onValueChange: (T) -> Unit. The composable becomes stateless: it has no memory of its own, so the exact same inputs always produce the exact same output, which is precisely what makes it trivial to preview, screenshot-test, and reuse in a context you didn't originally design it for.
#Stateful vs. stateless, side by side
// Stateless — this composable owns no state of its own. It cannot decide// anything; it can only display `name` and ask for a new one.@Composablefun NameInput(name: String,onNameChange: (String) -> Unit,modifier: Modifier = Modifier,) {OutlinedTextField(value = name,onValueChange = onNameChange,modifier = modifier,label = { Text("Name") },singleLine = true,)}// Stateful wrapper — this is the composable that decides WHERE the state// actually lives, and it's the only thing that changes if that answer changes.@Composablefun NameInputScreen() {var name by rememberSaveable { mutableStateOf("") }NameInput(name = name, onNameChange = { name = it })}
Nothing about NameInput changed when we decided the state should live in rememberSaveable rather than, say, a ViewModel's StateFlow — because NameInput never touched the state directly in the first place. That decoupling is the entire payoff of hoisting: the composable that renders UI and the composable (or class) that owns state can evolve independently, and you can write a Compose preview or a unit test for NameInput by simply passing a fixed String and a no-op lambda, with zero setup.
#How far up should you hoist?
A common misconception is that "hoisting" always means "put it in a ViewModel." The actual rule is narrower and more useful: hoist state to the lowest common ancestor of every composable that needs to read or write it, and no further. If two sibling composables need to share a selected tab index, hoisting it to their shared parent composable is enough — there is no reason to route it through a ViewModel just because a rule of thumb says so. State only needs to reach a ViewModel (or another layer that survives configuration changes and outlives the composition) when it must survive process death, be shared across screens, or depend on business logic and data sources the UI layer shouldn't know about.
#The full loop, end to end
In a real screen backed by a ViewModel, unidirectional data flow becomes a loop rather than a one-way street: the UI calls a function on the ViewModel in response to a user action, the ViewModel runs business logic (possibly through a use case or repository), produces a new immutable UiState, and that new state flows back down through composable parameters, causing exactly the parts of the tree that read it to recompose.
Note the direction of every arrow: state only ever travels down into the UI, and only events travel up out of it. There is no arrow where the UI reaches sideways into a shared mutable object and changes it directly — that omission is the whole architecture.
#A table worth memorizing for interviews
| Layer | Owns state? | Receives | Emits |
|---|---|---|---|
Stateless composable (NameInput) | No | value, onValueChange | Nothing — it's a leaf |
Stateful wrapper composable (NameInputScreen) | Yes, via remember/rememberSaveable | Nothing from outside | Renders the stateless composable with current state + a setter |
| ViewModel-backed screen | Yes, via StateFlow/SavedStateHandle | UI events (function calls) | UiState (collected with collectAsStateWithLifecycle) |