Jetpack Compose Mechanisms, Part 2: How the Runtime Remembers and How the UI Becomes Pixels

skydovesJaewoong Eum (skydoves)||25 min read

Jetpack Compose Mechanisms, Part 2: How the Runtime Remembers and How the UI Becomes Pixels

In Part 1, the compiler turned a plain function into one that can skip itself and run again on its own. That rewrite explains how a composable restarts, but not when it restarts, and not where the values it needs between runs actually live. You rely on the answers every day: you wrap a value in remember, reach for mutableStateOf, and the screen updates. But when remember alone never updates the screen, or mutableStateOf without remember keeps resetting, the explanation has to come from the runtime. And once the runtime knows what changed, Compose UI still has to turn that record into something Android can draw.

In this article, you'll dive deep into the Compose runtime and the UI layer, starting from four ways of holding a value and working toward pixels: the SlotTable and its gap buffer, positional memoization, state reads as subscriptions, frame aligned recomposition, the Applier, the Layout primitive, and AndroidComposeView.

This is the second article of a three part series based on my DroidKaigi 2026 & Droidcon Berlin 2026 talk, Jetpack Compose Mechanisms. You can follow along with the slides, and the diagrams in this series come from them. Part 1 covered the compiler, and Part 3 applies the whole picture to performance work. The source excerpts in this article come from the Compose 1.13.0-beta01 sources.

Two triggers: Input changes and state reads

Part 1 ended with a function that decides, every time it is called, whether its body needs to run. That covers one of the two ways a composable runs again:

  1. Input changes: when its caller runs again, the generated code compares each argument with the value stored on the previous run, and runs the body if any of them differs. This is the $composer.changed(name) check from Part 1.
  2. State reads: a composable that reads a State is recorded as depending on it. When that state changes, the runtime schedules the nearest restartable scope that did the read to run again, even if its caller never runs.

Either one is enough to run the body again. The first needs nothing from you beyond passing arguments. The second is the one you set up with mutableStateOf, almost always wrapped in remember, and that pairing is where most of the confusion starts.

The fundamental problem: Four ways to hold a value

Here are four alternative versions of the same composable. Each one holds a Dove, and they differ only in whether they use remember, mutableStateOf, or both:

@Composable
fun BirdProfile() {
    var bird = Dove()
}

@Composable
fun BirdProfile() {
    var bird = remember { Dove() }
}

@Composable
fun BirdProfile() {
    var bird = mutableStateOf(Dove())
}

@Composable
fun BirdProfile() {
    var bird = remember { mutableStateOf(Dove()) }
}

You probably know that remember and mutableStateOf belong together. The question is what each one contributes on its own, and the answer splits along the two halves of the runtime: one half keeps values between runs, the other reports changes.

Without remember: A new value on every run

Start with the plain version. A composable can run many times, and every run executes the body from the top, so a plain Dove() call creates a new instance every time:

Without remember, every recomposition creates a new Dove instance

Two recompositions mean three runs and three different Dove instances. Nothing connects one run's bird to the next run's, because the local variable lives exactly as long as one execution of the function.

remember: Storing the value in the SlotTable

Wrap the call in remember, and every run gets the same instance back:

With remember, every recomposition gets the same Dove instance back

The value has to live somewhere between runs. The composition keeps everything it records in a structure called the SlotTable: the groups from Part 1, and the values recorded inside them, each in a slot. remember writes its value into a slot and reads it back on the next run. A local value you create without remember is never written there, so the next run has nothing to read back. Arguments are a different matter: as Part 1 showed, changed() keeps each argument it compares in a slot, which is why the diagrams below contain values like "Count: 0".

Looking at the implementation of remember:

@Composable
public inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

It delegates to Composer.cache:

public inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

Notice the structure:

  1. Read: rememberedValue() returns the value stored at this same point in the composition on the previous run, or Composer.Empty if nothing has been stored yet or the content is being reused.
  2. Compute once: on the first composition, or when invalid is true, the lambda runs and updateRememberedValue stores its result.
  3. Reuse: on every later run, the stored value comes back and the lambda does not run at all.

remember with keys only changes the invalid flag. remember(key1) { ... } calls cache(currentComposer.changed(key1), calculation), the same changed you saw in Part 1, so the value is recomputed only when the key no longer equals the one stored last time. In compiled code, the compiler inlines this logic and compares keys itself. A key that is a stable parameter can reuse the $dirty bits from Part 1, and a constant key is never compared or stored at all. A parameter known to be unstable always gets its own changed() call, while one whose stability is only decided at runtime, such as a List since Kotlin 2.4, gets that call only when the caller marks the argument as unstable. Part 3 uses keyed remember to stop a list from being rebuilt.

Inside the SlotTable: Groups, slots, and a gap buffer

To see how the table is laid out, follow a small counter through two compositions:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    if (count > 0) {
        Icon(Icons.Default.Star, contentDescription = null)
    }
    Text("Count: $count")
}

The composer walks the SlotTable in the same order as the code runs, like a cursor moving through a document. The diagrams below flatten the table into a single column to keep the picture readable; the real table keeps groups and slots in two separate arrays. They also write the state's current value into the code, so mutableStateOf(1) in a later diagram means the state now holds 1, not that the source changed.

On the first composition, the recompose scope, the remembered state, and the Text call are recorded in order. count is 0, so the if block does not run, and apart from an empty group that marks the branch, nothing is recorded for Icon. The empty entries after them are the gap.

On the first composition, Icon is skipped and the empty entries at the end form the gap

Now count changes to 1, and recomposition walks the same code again. This time the if block runs, and Icon needs room in the middle of the table, between the state and the Text call.

This is where the gap buffer comes in. A gap buffer is the data structure many text editors use for the document you type into: the text lives in one array with a block of empty space, the gap, kept at the cursor. Typing fills the gap without moving anything else, and moving the cursor moves the gap. The SlotTable works the same way, with one detail the diagrams compress. While your code runs, the composer only reads the existing table. It composes new content such as Icon into a separate insert table, and it records the edits it needs in a change list. When those changes are applied, a writer moves the gap to the insertion point. That move copies the entries in between, here the two entries of the Text call, to the other side of the gap, and Icon's entries are then written into the empty space.

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor