State hoisting
Move state out of the composable that draws it. The piece becomes reusable, previewable and honest — and two parts of your screen can never disagree about what is true.
The bug you have not hit yet
Everything you built in Part 3 kept its state where it was used. A counter remembered its own count. A switch remembered its own on-or-off.
That works right up until the moment two things need the same fact.
Picture a shopping list screen with a "3 items" badge at the top and a list underneath. If the list remembers the items and the badge remembers the count, you now have two truths. Delete an item and the list says two while the badge still says three. Nobody wrote a bug. The bug is the shape.
is the fix, and it is one of about five ideas that separate an app that works from an app that keeps working.
Think about the scoreboard at a school sports day.
You could let each team keep its own score in its head. It is simpler — nobody has to walk to the scoreboard. It also falls apart within ten minutes, because the red team remembers 7 and the blue team remembers that red has 6, and now there is an argument nobody can settle.
So instead there is one board on the wall. Teams do not write on it. They shout "we scored!" and the person holding the chalk updates it. Everybody reads the same board.
The board is the . The teams are composables: they display and they report, but they do not decide.
Two versions of the same button
Here is a counter that owns its state. It is the version you would write first, and there is nothing wrong with it — until there is.
1@Composable
2fun Counter() {
3 var count by remember { mutableStateOf(0) }
4 Button(onClick = { count++ }) {
5 Text("Tapped $count times")
6 }
7}Now the hoisted version. The state has moved out. What is left takes a value and a lambda.
1@Composable
2fun Counter(
3 count: Int,
4 onTap: () -> Unit
5) {
6 Button(onClick = onTap) {
7 Text("Tapped $count times")
8 }
9}Read those two side by side. The second one has no remember, no var, no mutableStateOf. It cannot change anything. Hand it the same count twice and it draws the same thing twice, forever.
That property has a name: the composable is now stateless. It is a pure description of pixels, given some data.
Who holds it now?
Somebody still has to. The state moves up to whoever needs it — usually the closest parent that contains everything that cares.
1@Composable
2fun CounterScreen() {
3 var count by remember { mutableStateOf(0) }
4
5 Column {
6 Text("Total so far: $count")
7 Counter(count) { count++ }
8 Counter(count) { count++ }
9 }
10}Two buttons, one number. Tap either and both buttons update, and the total at the top updates, because there is only one number in the entire screen. There is nothing left to get out of step.
The rule of thumb: hoist to the lowest common parent of everything that reads or changes that state. Not higher. State hoisted further than it needs to go makes every layer in between carry values it does not care about.
State down, events up
The hoisted Counter has exactly two kinds of parameter, and almost every well-shaped composable in Android has the same two:
| Parameter | Direction | What it means |
|---|---|---|
count: Int | down | Here is what to show. |
onTap: () -> Unit | up | Tell me when something happened. |
Data flows down. Events flow up. Nothing goes sideways, and nothing goes down and then quietly back up again through the same door. That loop has a name — — and it is the reason you can look at a Compose screen and work out where a value came from.
Notice the naming convention too. A parameter called value pairs with a lambda called onValueChange. checked pairs with onCheckedChange. Material's own components are built exactly this way, which is why Switch needs both.
You have already done this
Open Dice Duel and look at DieFace:
That single design choice is why the same composable appears twice in the app — once as the big playable die, once as a small trophy on the win banner — with no duplicated code and no flags.
What this buys you
Reuse. One DieFace, two jobs.
Previews. A stateless composable can be drawn in a @Preview with made-up values, because you can just hand it a five. A stateful one draws whatever it happens to remember.
Testing. You can check that passing 6 draws six pips without launching an app.
A place to go next. Once state has left the composable, it can keep going up: out of the screen entirely, into a that survives the screen being destroyed. That is the next lesson, and it is only possible because you hoisted first.
Hoisting is not free. If a value is genuinely private — whether a dropdown is currently open, how far a text field is scrolled — leave it where it is. Hoisting everything produces a screen with forty parameters, which is its own kind of unmaintainable.
The test: does anything else need to know? If no, keep it local.
You will hoist a real piece of state out of Dice Duel's turn hint.
- Open Pocket Studio and tap Projects, then Dice Duel.
- Tap Editor and open
GameScreen.kt. - Scroll to
private fun TurnHint(turn: Int, rolling: Boolean). Notice it has norememberanywhere — it is already stateless. - Now find
var turn by remember { mutableStateOf(1) }near the top ofGameScreen. That is the scoreboard on the wall. - Add a second
TurnHint(turn = turn, rolling = rolling)immediately below the first one, inside the sameColumn. - Tap Run. Two hints, always identical, because there is one
turn. - Delete the extra line and press Run again to put it back.
by remember { mutableStateOf(0) } but the two tiny imports that make by work on state are missing.import androidx.compose.runtime.getValue and import androidx.compose.runtime.setValue. Pocket Studio's quick-fix on the underlined by adds both.by and pass count, or declare it with = and pass count.value. Do not change the child to accept MutableState<Int> — that hands the child the chalk again.count++. Parameters in Kotlin are always read-only.onTap() — and let the owner do the changing.onClick lambda, which runs on a tap, not during drawing.if. An onClick may only change state; changing state is what causes the drawing to happen.var rather than in mutableStateOf, so nothing tells Compose to redraw. println shows the new value; the screen shows the old one.var count by remember { mutableStateOf(0) }. Compose only watches values it was asked to watch.- Two things holding their own copy of the same fact will eventually disagree. One removes the whole category of bug.
- Hoisting means taking the state out of a composable, leaving it with a value parameter and an event lambda.
- State flows down, events flow up. That is .
- Hoist to the lowest common parent of everything that cares — no higher. Truly private state stays put.
- Stateless composables are reusable, previewable and testable. Dice Duel's
DieFaceis one. - Next: state has left the composable, but it still dies when the screen does. Rotate the phone and Dice Duel forgets the score. The fixes that.