Recomposition, explained properly
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.
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.
1DuelScreen
2├── TitleBlock
3├── ScoreBoard(p1 = 12, p2 = 9)
4│ ├── PlayerPanel(name = "PLAYER 1", score = 12)
5│ └── PlayerPanel(name = "PLAYER 2", score = 9)
6└── RollButtonThat 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:
ScoreBoardre-runs, because it readsp1.PlayerPanel(name = "PLAYER 1", score = 12)re-runs, becausescoreis now 13.PlayerPanel(name = "PLAYER 2", score = 9)is skipped. Same name, same score, nothing to do.TitleBlocknever 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.
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:
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:
val sorted = remember(rolls) { rolls.sorted() }Now the sort runs when rolls changes and not one time more.
The worst version of breaking rule 3 is writing state during composition:
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:
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}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?
| Line | Body? | Where it belongs |
|---|---|---|
Text("Score: $score") | Yes | It is the description |
val label = if (n > 9) "Lots" else "$n" | Yes | Cheap, and derived from what you were given |
var n by remember { mutableStateOf(0) } | Yes | remember exists to be called here |
n++ | No | An onClick, or an effect |
db.loadNotes() | No | A LaunchedEffect, or a ViewModel |
delay(1000) | No — will not compile | A LaunchedEffect |
list.sortedBy { it.score } on 10,000 items | No | remember(list) { ... } |
Random.nextInt(1, 7) | No | An 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.
- Open ComposeLab and type the
RollEffectcomposable from this lesson. - Accept the imports Pocket Studio offers. The new ones are
androidx.compose.runtime.LaunchedEffect,kotlinx.coroutines.delayandkotlin.random.Random. - Tap Run, then tap Roll. The number should flicker nine times over about half a second and then settle.
- Now break it on purpose. Delete the
LaunchedEffectline and its closing brace, so therepeatblock sits directly in the composable body. Try to build. Read the error aboutdelay— the compiler is stopping you from freezing the screen. - 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. - Finally, change
LaunchedEffect(rollId)toLaunchedEffect(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.
delay(...) straight into a composable body. A composable is not a coroutine, and if it could pause, the whole screen would freeze with it.LaunchedEffect(key) { ... }. The block inside a LaunchedEffect is a coroutine, so suspend functions are welcome there.onClick = { ... }. A click handler runs later, when composition is long over, so nothing composable can happen there.if (showDialog) { MyDialog() }.remember, mutableStateOf used as a Compose value, a MaterialTheme lookup — was called from ordinary code such as a helper function or a callback.@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.try { } catch { }. Composition can be paused, restarted and abandoned, and a try-block cannot survive that.if (error != null) { ErrorMessage(error) }.onClick, or into LaunchedEffect(key) { ... } so it runs when the key changes rather than on every pass.- 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 asdelay. - 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.