Zygote
UI toolkitsnerd

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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#

Loading diagram…
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 inInvalidates
compositioncomposition (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.

Loading diagram…
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:

  1. Marks the scope invalid.
  2. Schedules a recomposition with the frame clock.
  3. Re-enters the scope's lambda, skipping children whose parameters are unchanged and whose groups can be reused.
  4. 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()
        }
    }
}
EffectRuns
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
SideEffectafter 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#

Interface changes16 releases
APIVersionWhat changed
11.0
  • Pull-down notification shade
  • Home screen with widgets
31.5 Cupcake
  • Home screen widgets and folders
92.3 Gingerbread
  • New UI with dark theme and green accents
113.0 Honeycomb
  • The Holo design language arrives
123.1 Honeycomb
  • Resizable widgets
164.1 Jelly Bean
  • Project Butter: vsync-driven, smooth UI
  • Expandable notifications
204.4W KitKat (Wear)
  • Voice-driven card UI
215.0 Lollipop
  • Material Design
  • Notifications on the lock screen
247.0 Nougat
  • Quick Settings tiles
  • Direct Reply in notifications
268.0 Oreo
  • Notification channels
289 Pie
  • Dark theme groundwork
2910
  • System-wide dark theme
3011
  • Conversation notifications and bubbles
  • Media controls in Quick Settings
3112
  • Material You dynamic colour
3313 Tiramisu
  • Themed app icons
  • Runtime notification permission
3616 Baklava
  • Material 3 Expressive
  • Live Updates notifications

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#

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 collectAsStateWithLifecycle and LaunchedEffect exist.
  • 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.
  • remember does not survive process death. It survives recomposition and, in a LazyColumn with keys, scrolling. For anything else, use rememberSaveable.

Try it#

Enable recomposition counts in a debug build

# In Android Studio: Layout Inspector → check "Show recomposition counts" adb shell setprop debug.compose.recomposition.counts true

Find unstable parameters in a build

./gradlew :app:compileDebugKotlin -PcomposeCompilerReports=true

Check yourself#

Check yourself

1 / 12

What is the difference between `invalidate()` and `requestLayout()`?

0 answered · 0 correct

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.

  1. docsCompose phasesdeveloper.android.comaccessed 2026-10-27↩
  2. docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
  3. docsCompose performancedeveloper.android.comaccessed 2026-10-27↩
  4. docsCompose stabilitydeveloper.android.comaccessed 2026-10-27↩
  5. docsLifecycle of composablesdeveloper.android.comaccessed 2026-10-27↩