How Compose works
Compose is a compiler plugin, a positional memory table and a snapshot system. Once you can see those three, recomposition stops being magic and becomes predictable.
Ask an experienced Compose developer what happens when state changes and you will get three words: composition, layout, draw.1 The interesting part is what happens between them, and why one is often skipped.
TL;DR#
- Compose runs in three phases: composition (what UI exists), layout (where it goes), draw (what it looks like).
- builds and maintains a tree of groups in the slot table, a positional structure that also stores ed values.
- re-executes only the composables whose read state changed, tracked through snapshot state.
- State reads are recorded per phase. Reading state in composition invalidates composition; reading it in layout invalidates only layout.
- Keys determine identity in lists; without them, position is identity, and state shifts when the list changes.2
Mental model#
Think of a theatre production.
Composition is casting and staging: who is on stage, in what roles, in what order. Layout is blocking: where each actor stands. Draw is the performance: what the audience sees.
When the script changes, you do not recast everyone. You change the scene that changed, re-block it, and perform it. Compose's runtime is the stage manager keeping the cast list, the blocking notes and the running order in a book — that book is the slot table.
Where the analogy breaks#
A theatre keeps its book in one place. Compose's book is a stack with positional addressing: it is fast because it walks the tree in the same order every time and matches by position, not by name. That is exactly why keys matter — they are the exception to positional matching, and the only way to keep an item's identity when its position changes.
The big picture#
Text description of this diagram
An interactive simulated Compose tree. State-change buttons — count++, new message, theme change — highlight which composables re-execute. Toggles control whether a parameter is unstable (a List or a class without equals), whether the event lambda is remembered, and whether derivedStateOf is used. With stable parameters and a remembered lambda, only the composables that read the changed state re-execute; an unstable parameter widens the scope to every child of the parent, and a non-remembered lambda prevents the receiving child from ever being skipped. derivedStateOf narrows the read so only its consumer recomposes. A counter reports how many of the tree's nodes were re-executed.
The difference between "2 of 8 nodes recomposed" and "7 of 8" is the practical content of this page. The tree is the same; only the read tracking changed.
Deep dive#
Phase 1: composition#
@Composable
fun Counter(count: Int, onIncrement: () -> Unit) {
Column {
Text("Count: $count")
Button(onClick = onIncrement) { Text("Add") }
}
}The compiler turns that into a function that participates in a group structure. Each composable call
site opens a group, and the runtime records it in the slot table along with any remembered
values at that position.
// The shape the compiler produces, simplified.
@Composable
fun Counter(count: Int, onIncrement: () -> Unit, $composer: Composer, $changed: Int) {
$composer.startRestartGroup(KEY)
// … group content …
$composer.endRestartGroup()?.updateScope { c ->
Counter(count, onIncrement, c, $changed or 1)
}
}updateScope is the mechanism that lets the runtime re-invoke this function later without
re-running its parent.3 That is the "restart scope" idea, and it is why Compose can recompose a leaf
without touching the tree above it.
Snapshot state and read tracking#
mutableStateOf returns a MutableState backed by the snapshot system. Writes inside a
composition are tracked; when a state object changes, the runtime invalidates every scope that read it.
val count = remember { mutableStateOf(0) }
// or, for a value type:
var count by remember { mutableIntStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }The important detail is the phase of the read:
| Read in | Invalidates |
|---|---|
| composition | composition (re-execute the scope) |
layout (Modifier.offset { }) | layout only |
draw (Modifier.drawBehind { }) | draw only |
That is why Modifier.offset { IntOffset(x, 0) } — the lambda version — is cheaper than
Modifier.offset(x.dp, 0.dp) for an animation. The lambda defers the read to the layout phase, so a
changing offset does not recompose anything.1
The three phases in a layout#
@Composable
fun ProgressRing(progress: Float) {
Canvas(
modifier = Modifier
.size(120.dp)
.drawBehind {
drawArc(
color = Color.Blue,
startAngle = -90f,
sweepAngle = 360f * progress,
useCenter = false,
)
}
)
}drawBehind reads progress in the draw phase. When progress changes, the runtime invalidates
draw for that node — no recomposition, no re-layout. For an animation ticking at 60 Hz, that is the
difference between executing a composable sixty times a second and executing a draw command sixty times
a second.
Stability, and why it decides skipping#
A composable can be skipped if every parameter is stable and equal to the previous value.4 Stable
means: the type is immutable, or it is a class whose properties are all val of stable types, or it is
annotated @Stable.
// Skippable: String and a stable lambda.
@Composable
fun Title(text: String, onClick: () -> Unit) { … }
// Not skippable: List is an interface, so the compiler cannot prove immutability.
@Composable
fun MessageList(messages: List<Message>) { … }List is the canonical unstable type. The compiler cannot know whether the list was mutated, so it
must recompose. Two fixes:
// 1. Use an immutable collection type.
@Composable
fun MessageList(messages: ImmutableList<Message>) { … }
// 2. Or tell the compiler, with a stability configuration or @Immutable on the model.
@Immutable
data class Message(val id: String, val text: String, val author: String)Remember, and the lambda trap#
// Wrong: a new lambda on every recomposition means Button can never skip.
@Composable
fun Counter(count: Int, onReset: () -> Unit) {
Button(onClick = { onReset() }) { Text("Reset") }
}
// Still wrong: the lambda is remembered but captures a changing value.
@Composable
fun Counter(count: Int) {
val onReset = remember { { /* uses count */ } } // stale closure
}
// Right: remember with a key, or use rememberUpdatedState.
@Composable
fun Counter(count: Int, onReset: () -> Unit) {
val currentReset by rememberUpdatedState(onReset)
val lambda = remember { { currentReset() } }
Button(onClick = lambda) { Text("Reset") }
}rememberUpdatedState is the idiomatic fix for the stale-closure problem: it gives you a stable
reference whose value is always the latest.
Keys and identity#
LazyColumn {
items(notes, key = { it.id }) { note ->
NoteRow(note)
}
}Without a key, an item's identity is its position. Insert an item at the top and every row's remembered state shifts by one. With a key, the runtime matches groups by key and moves state with the item.
Text description of this diagram
An interactive slot table walkthrough. The first step shows a column with three rows — Alice, Bob and Carol — each owning a remembered value at a position. The second step inserts a new item at the top without keys: the new row takes position one, and every existing row's remembered state now belongs to the row above it. The third step shows the same insertion with key() applied: the new item receives a new group, and the existing rows keep their slots and their state. The fourth step shows a conditional branch, where removing a composable removes its groups and later positions shift down.
How a rebuild actually happens#
It is worth being precise about what "recompose" means, because the mental picture of "re-run the whole function" is wrong in a way that leads to bad code.
When state read by a restart scope changes, the runtime:
- Marks the scope invalid.
- Schedules a recomposition with the frame clock.
- Re-enters the scope's lambda, skipping children whose parameters are unchanged and whose groups can be reused.
- Applies the resulting changes to the composition, then runs layout and draw for the affected nodes.
Step 3 is why parameters matter more than code structure. A composable that takes the whole state object recomposes when any field changes; one that takes only the field it needs recomposes when that field changes. The compiler cannot fix that for you, but the stability diagnostics will tell you when a parameter defeats skipping.
The frame clock#
Compose does not recompose immediately on every write. Writes are applied to a snapshot, and the runtime
schedules work with the MonotonicFrameClock, which on Android is backed by Choreographer. That is
why a burst of state changes within one frame produces one recomposition, not one per change.
// One recomposition, not three: the writes are batched into a frame.
repeat(3) { count++ }The batching is also why reading state outside a composition and writing it in a loop does not show intermediate values — the UI only ever sees the state as of the frame.
CompositionLocal: implicit parameters#
val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState?> { null }
@Composable
fun Screen() {
CompositionLocalProvider(LocalSnackbarHost provides host) {
Content()
}
}CompositionLocal passes a value down the composition without threading it through every signature.
The trade-off is the usual one: it makes a composable's dependencies invisible at the call site. Use
it for genuinely ambient values — theme, density, layout direction, a snackbar host — not for ordinary
data.
Use staticCompositionLocalOf when the value rarely changes (it invalidates the whole subtree when it
does), and compositionLocalOf when it changes often (it tracks reads individually).
Effects and lifecycle#
@Composable
fun Ticker(onTick: () -> Unit) {
val latest by rememberUpdatedState(onTick)
LaunchedEffect(Unit) {
while (true) {
delay(1000)
latest()
}
}
}| Effect | Runs |
|---|---|
LaunchedEffect(key) | launches when the composable enters with this key; cancels when the key changes or the composable leaves |
DisposableEffect(key) | runs on enter, and onDispose on leave or key change |
SideEffect | after every successful composition |
rememberCoroutineScope() | a scope cancelled when the composable leaves |
A composable should not start work directly in its body. The body may run many times; an effect is the declared boundary between "describe" and "do".
Reading the compiler's diagnostics#
The Compose compiler can emit a report per module listing every composable, its parameters and whether it is restartable and skippable. That report is the most direct answer to "why is this recomposing".
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}A composable marked restartable but not skippable is the one to look at: something in its parameter
list is unstable, and the report names it. That is a five-minute fix in most codebases, and it is
invisible without the report.
Composition is not a function call#
The last mental correction: a composable call is not a normal function call that returns a value. It registers a group in the slot table, emits nodes into the composition, and may be skipped or restarted independently of its caller.
That is why a composable cannot return a view, why it cannot be called conditionally in the ordinary sense (it can, but the group structure changes), and why the compiler needs to know which functions are composable. Once the group model is clear, most of Compose's rules stop being arbitrary.
Why recomposition can loop#
A composable that writes state it also reads will recompose forever. The runtime detects some cases, but not all:
// Infinite recomposition: reading and writing the same state in composition.
@Composable
fun Bad() {
val state = remember { mutableStateOf(0) }
state.value++ // writes during composition
Text("${state.value}")
}The fix is always the same: write state in an event handler or an effect, never in the composition body.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
The runtime has been stable since 1.0. The changes have been in tooling — the Layout Inspector's composition tree, the recomposition counts in Android Studio — and in compiler diagnostics, which now warn about unstable parameters and non-skippable composables.
Under the hood#
- Choreographer.javathe vsync source Compose's frame clock uses
- View.javathe host view a composition draws into
- the renderer behind Compose's draw phase
Curiosity corner#
- Recomposition can happen on any thread in principle, but state writes that a composition reads
must be applied on the main thread. That is why
collectAsStateWithLifecycleandLaunchedEffectexist. - A composable can be called outside composition — in a test — with a fake
Composer. That is how Compose UI tests work without a device activity. rememberdoes not survive process death. It survives recomposition and, in aLazyColumnwith keys, scrolling. For anything else, userememberSaveable.
Try it#
# In Android Studio: Layout Inspector → check "Show recomposition counts"
adb shell setprop debug.compose.recomposition.counts true
./gradlew :app:compileDebugKotlin -PcomposeCompilerReports=true
Check yourself#
Check yourself
What is the difference between `invalidate()` and `requestLayout()`?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsCompose phasesdeveloper.android.comaccessed 2026-10-27↩
- docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
- docsCompose performancedeveloper.android.comaccessed 2026-10-27↩
- docsCompose stabilitydeveloper.android.comaccessed 2026-10-27↩
- docsLifecycle of composablesdeveloper.android.comaccessed 2026-10-27↩