Pocket Studio Academy
HomePart 44.1

State hoisting

Full course11 min read·4 questions

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 of it like this

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.

Counter.kt — statefulkotlin
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.

Counter.kt — hoistedkotlin
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.

CounterScreen.ktkotlin
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.

Tip

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:

ParameterDirectionWhat it means
count: IntdownHere is what to show.
onTap: () -> UnitupTell 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.

Careful

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.

Try it in Pocket Studio

You will hoist a real piece of state out of Dice Duel's turn hint.

  1. Open Pocket Studio and tap Projects, then Dice Duel.
  2. Tap Editor and open GameScreen.kt.
  3. Scroll to private fun TurnHint(turn: Int, rolling: Boolean). Notice it has no remember anywhere — it is already stateless.
  4. Now find var turn by remember { mutableStateOf(1) } near the top of GameScreen. That is the scoreboard on the wall.
  5. Add a second TurnHint(turn = turn, rolling = rolling) immediately below the first one, inside the same Column.
  6. Tap Run. Two hints, always identical, because there is one turn.
  7. Delete the extra line and press Run again to put it back.
Error Doctor5 common errors
e: Type 'MutableState<Int>' has no method 'getValue(Nothing?, KProperty<*>)' and thus it cannot serve as a delegate
MeansYou wrote by remember { mutableStateOf(0) } but the two tiny imports that make by work on state are missing.
FixAdd import androidx.compose.runtime.getValue and import androidx.compose.runtime.setValue. Pocket Studio's quick-fix on the underlined by adds both.
e: Type mismatch: inferred type is MutableState<Int> but Int was expected
MeansYou hoisted the state but passed the whole state holder down instead of its value. The child asked for a number and got the box the number lives in.
FixEither declare it with 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.
e: Val cannot be reassigned
MeansInside the hoisted composable you tried to write to the parameter, for example count++. Parameters in Kotlin are always read-only.
FixA stateless composable must not change its input. Call the lambda instead — onTap() — and let the owner do the changing.
e: @Composable invocations can only happen from the function context of another @Composable function
MeansYou called a composable from ordinary code — very often from inside an onClick lambda, which runs on a tap, not during drawing.
FixMove the composable call into the body of the composable and control it with a plain if. An onClick may only change state; changing state is what causes the drawing to happen.
The screen does not update when I tap, but the value is definitely changing
MeansThe value is held in a plain var rather than in mutableStateOf, so nothing tells Compose to redraw. println shows the new value; the screen shows the old one.
FixWrap it: var count by remember { mutableStateOf(0) }. Compose only watches values it was asked to watch.
Recap
  • 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 DieFace is 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.