Pocket Studio Academy
HomePart 33.6

Recomposition, explained properly

Full course11 min read·3 questions

What actually happens between your tap and the new number on screen. Which functions Compose re-runs, which it skips, and the four things you must never do inside a composable body — with the real place to put them instead.

Nobody redraws the screen

In Lesson 3.5 you wrote rolls++ and a number changed on the glass, and there was no line of code in between. That should bother you slightly. Something happened, and you did not write it.

What happened is , and it is worth understanding properly rather than as magic — because once you know the rules, a whole class of baffling bugs becomes obvious at a glance.

Think of it like this

Open a spreadsheet.

Cell A1 holds 12. Cell B1 holds =A1*2 and shows 24. Cell C1 holds =NOW().

Change A1 to 20. B1 becomes 40 instantly. You did not tell it to. You did not write "when A1 changes, recalculate B1". You wrote a formula that reads A1, and reading was enough — the spreadsheet noted the dependency and recalculated exactly the cells that needed it. D1, which reads nothing, was not touched.

Compose is that spreadsheet, and your composables are the formulas. Reading a piece of is what puts your function on the list of things to recalculate.

Now look at C1 again. =NOW() is a formula whose answer depends on something the spreadsheet cannot see. It goes stale, it updates at unpredictable moments, and it makes the sheet impossible to reason about. Half of this lesson is about not writing C1.

The Composition: what Compose is actually holding

When setContent runs your top-level composable, Compose does not just draw and forget. It builds and keeps a structure called the Composition: a tree of every composable that ran, in order, with the values each one was given and the remember slots each one owns.

text
1DuelScreen
2├── TitleBlock
3├── ScoreBoard(p1 = 12, p2 = 9)
4│   ├── PlayerPanel(name = "PLAYER 1", score = 12)
5│   └── PlayerPanel(name = "PLAYER 2", score = 9)
6└── RollButton

That tree is the thing that gets updated. When state changes, Compose does not rebuild the tree from scratch and it certainly does not redraw the whole screen. It re-runs the smallest region of the tree it can get away with, compares the new description against the old one, and changes only the pixels that genuinely differ.

Two questions Compose asks

Question one: who read this?

When p1 changes from 12 to 13, Compose looks up which composables read p1 while they last ran. Reading is subscribing — that is the sentence from Lesson 3.5, and this is where it earns its keep. Only those functions are marked for re-running.

Question two: has anything this function depends on actually changed?

Even inside a region being recomposed, Compose checks each child. If a child's parameters are all equal to what they were last time, it is skipped — the function is not called at all, and its whole subtree is left alone.

So when player one's score goes up:

  • ScoreBoard re-runs, because it reads p1.
  • PlayerPanel(name = "PLAYER 1", score = 12) re-runs, because score is now 13.
  • PlayerPanel(name = "PLAYER 2", score = 9) is skipped. Same name, same score, nothing to do.
  • TitleBlock never even entered the conversation — it read nothing that changed.

You get this for free, on every screen, without writing a single line to manage it. It is the reason a Compose list of 200 rows stays smooth while one row's checkbox toggles.

Tip

Skipping is why you should pass a composable exactly the values it needs, and no more. A PlayerPanel(game = wholeGameState) re-runs whenever anything in the game changes. PlayerPanel(name = ..., score = ...) re-runs only when that player's numbers move. Small parameters are fast parameters.

The four rules of a composable body

Because Compose calls your functions — not you — the body of a composable has to obey rules that ordinary functions do not. Here they are, with what goes wrong when you break them.

1. It may run many, many times

A composable can run once, or sixty times a second during an animation, or a hundred times while someone drags a slider. Anything you do in the body, you are agreeing to do that many times.

2. It may be skipped entirely

If nothing it depends on changed, your function is simply not called. Any work you were relying on it doing does not happen.

3. It must not have side effects

A side effect is anything the function does that outlives the function and is visible from elsewhere: writing a file, sending a network request, showing a Toast, starting a timer, incrementing a counter that lives outside, adding to a shared list.

This is the big one. Look at this innocent-looking code:

kotlin
1@Composable
2fun Bad(score: Int) {
3    // Never do this.
4    logCounter = logCounter + 1
5    Text(text = "Score: $score")
6}

How many times does logCounter go up? Nobody knows. It depends on how many recompositions happen, which depends on animations, on the keyboard opening, on the phone rotating. The number is not wrong exactly — it is meaningless.

4. It must be fast

Reading a database, parsing a file, sorting ten thousand items — none of that belongs in a composable body, because rule 1 says you might be doing it sixty times a second. If a value is expensive to compute and depends only on your parameters, wrap it:

kotlin
val sorted = remember(rolls) { rolls.sorted() }

Now the sort runs when rolls changes and not one time more.

Careful

The worst version of breaking rule 3 is writing state during composition:

kotlin
1@Composable
2fun Spinner() {
3    var n by remember { mutableStateOf(0) }
4    n++          // writes state while composing
5    Text("$n")
6}

Writing state schedules a recomposition. The recomposition runs the body. The body writes state. There is no error message and nothing crashes — the app simply pins the processor, drains the battery and gets warm. If your phone heats up while a screen is idle, look for a write in a composable body.

Where the work actually goes

So if you cannot do things in the body, where do they go? Two places.

Events — onClick and friends. A click is not part of composition. It runs later, exactly once, because a human tapped something. Changing state there is not just allowed, it is the intended design.

Effects — LaunchedEffect and its relatives. For work that should happen because the screen is showing, rather than because someone tapped: start a timer, load some data, play a sound.

LaunchedEffect starts a tied to the composable. It runs when the composable first appears, cancels automatically if the composable leaves the screen, and restarts only when its key changes. Being a coroutine, it can call functions such as delay — which you cannot do in a composable body at all.

Here is Dice Duel's rolling animation, cut down to its bones:

RollEffect.ktkotlin
1@Composable
2fun RollEffect() {
3    var die by remember { mutableStateOf(5) }
4    var rollId by remember { mutableStateOf(0) }
5    var rolling by remember { mutableStateOf(false) }
6
7    LaunchedEffect(rollId) {
8        if (rollId == 0) return@LaunchedEffect
9        repeat(9) {
10            die = Random.nextInt(1, 7)
11            delay(60)
12        }
13        die = Random.nextInt(1, 7)
14        rolling = false
15    }
16
17    Column(
18        modifier = Modifier
19            .fillMaxSize()
20            .padding(24.dp),
21        verticalArrangement =
22            Arrangement.spacedBy(12.dp),
23        horizontalAlignment =
24            Alignment.CenterHorizontally
25    ) {
26        Text(text = "You rolled: $die")
27        Text(
28            text = if (rolling) {
29                "Rolling..."
30            } else {
31                "Tap to roll"
32            }
33        )
34        Button(
35            onClick = {
36                if (!rolling) {
37                    rolling = true
38                    rollId++
39                }
40            }
41        ) {
42            Text(text = "Roll")
43        }
44    }
45}
9:41▲ ▮
You rolled: 2
Rolling...
Roll

RollEffect() mid-throw: rolling is true, and die is flickering.

A checklist you can actually use

Before you put a line in a composable body, ask: would it be a problem if this ran a hundred times, or not at all?

LineBody?Where it belongs
Text("Score: $score")YesIt is the description
val label = if (n > 9) "Lots" else "$n"YesCheap, and derived from what you were given
var n by remember { mutableStateOf(0) }Yesremember exists to be called here
n++NoAn onClick, or an effect
db.loadNotes()NoA LaunchedEffect, or a ViewModel
delay(1000)No — will not compileA LaunchedEffect
list.sortedBy { it.score } on 10,000 itemsNoremember(list) { ... }
Random.nextInt(1, 7)NoAn event or an effect — otherwise the die re-rolls itself on every redraw

That last row is the one that surprises people. Put a random number in a composable body and it changes every time anything on the screen recomposes. The die would reroll while the player was looking at it.

Try it in Pocket Studio
  1. Open ComposeLab and type the RollEffect composable from this lesson.
  2. Accept the imports Pocket Studio offers. The new ones are androidx.compose.runtime.LaunchedEffect, kotlinx.coroutines.delay and kotlin.random.Random.
  3. Tap Run, then tap Roll. The number should flicker nine times over about half a second and then settle.
  4. Now break it on purpose. Delete the LaunchedEffect line and its closing brace, so the repeat block sits directly in the composable body. Try to build. Read the error about delay — the compiler is stopping you from freezing the screen.
  5. Undo that. Instead, add this line directly in the body, above the Column: die = Random.nextInt(1, 7). Run it. The die now changes every time anything recomposes, including when you tap the button. Delete it.
  6. Finally, change LaunchedEffect(rollId) to LaunchedEffect(Unit) and Run. Tap Roll twice — only the first tap does anything, because with a key that never changes the effect never runs again. Change it back.
Error Doctor5 common errors
e: Suspend function 'delay' should be called only from a coroutine or another suspend function
MeansYou put delay(...) straight into a composable body. A composable is not a coroutine, and if it could pause, the whole screen would freeze with it.
FixMove the code into LaunchedEffect(key) { ... }. The block inside a LaunchedEffect is a coroutine, so suspend functions are welcome there.
e: @Composable invocations can only happen from the context of a @Composable function
MeansInside a composable this usually means you called a composable from somewhere that is not composable — most often inside onClick = { ... }. A click handler runs later, when composition is long over, so nothing composable can happen there.
FixDo not try to "call a screen" from a click. Set some state in the click handler, and let the composable body read that state and decide what to show: if (showDialog) { MyDialog() }.
java.lang.IllegalStateException: Function invoked outside of a composable context
MeansSomething that only works during composition — remember, mutableStateOf used as a Compose value, a MaterialTheme lookup — was called from ordinary code such as a helper function or a callback.
FixEither mark the calling function @Composable, or move the call up into the composable body and pass the result in as a parameter. Plain helper functions should take values, not fetch them.
e: Try catch is not supported around composable function invocations.
MeansExactly what it says: you cannot wrap a composable call in try { } catch { }. Composition can be paused, restarted and abandoned, and a try-block cannot survive that.
FixDo the risky work outside composition — in an effect or a ViewModel — catch the failure there, store the result in state, and let the composable read that state: if (error != null) { ErrorMessage(error) }.
The phone gets warm and the battery drains on a screen that is not doing anything
MeansAlmost certainly infinite recomposition: something in a composable body is writing state, which schedules a recomposition, which runs the body again. There is no error message for this.
FixScan every composable body for an assignment to a state value. Move it into an onClick, or into LaunchedEffect(key) { ... } so it runs when the key changes rather than on every pass.
Recap
  • Compose keeps a Composition — a tree of everything that ran. re-runs the smallest part of it that could have changed.
  • Reading a value subscribes the current composable to it. Children whose parameters are unchanged are skipped entirely.
  • A composable body may run many times, or none. So it must be fast, and free of side effects.
  • Never write state, start work, or generate a random number in a composable body. Warm phone, flat battery, and no error message.
  • Changes belong in event lambdas (onClick) or in effects (LaunchedEffect(key) { }), which can call functions such as delay.
  • Expensive derived values go in remember(key) { ... }.
  • Next: making text look like something — size, weight, colour, spacing, and the Material 3 type scale that keeps a whole app consistent.