Lesson 8

Understand state and identity

Trace a state mutation into an in-place node update and predict when identity is retained, moved, or replaced.

4 min readUpdated Jul 17, 2026

Learning goal

Trace a state mutation into an in-place node update and predict when identity is retained, moved, or replaced.

Counter example

@Composable
@AtlasComposable
fun Counter(count: Int, onIncrement: () -> Unit) {
    Group {
        Text("Count: $count")
        Button(label = "Increment", onActivate = onIncrement)
    }
}

@Composable
@AtlasComposable
fun RememberingCounter() {
    var count by remember { mutableStateOf(0) }
    Counter(count = count, onIncrement = { count++ })
}

Button is developed fully after events are introduced; for now, focus on its state-writing callback.

Mutation pipeline

Diagram
sequenceDiagram
    participant Event
    participant State
    participant Recomposer
    participant Composer
    participant Changes as recorded changes
    participant TextNode
    participant Host
    participant Renderer
    participant Surface

    Event->>State: count = count + 1
    State->>Recomposer: applied changes reach its observer
    Recomposer->>Composer: invalidate and recompose tracked scope
    Composer->>Changes: record changed TextNode property
    Recomposer->>Changes: apply committed change list
    Changes->>TextNode: text = "Count: 1"
    TextNode->>TextNode: invalidateLayout()
    Host->>Renderer: render committed tree
    Renderer->>Surface: submit later frame

More precisely, a write becomes relevant to the Recomposer when its snapshot is applied and apply observers are notified. Runtime then schedules compositions whose tracked reads were affected.

Positional identity

Compose identifies instances by compiler-generated call sites and surrounding groups. When one call site executes repeatedly, execution order distinguishes those instances unless key supplies identity.

Diagram
flowchart TD
    Parent[Group call site] --> P1[Position 1<br/>Text Count]
    Parent --> P2[Position 2<br/>Button Increment]
    P1 --> N1[retained TextNode]
    P2 --> N2[retained ButtonNode]

When count changes, position 1 still emits Text. Runtime can retain N1 and apply a property update.

This does not mean “the tree is never rebuilt.” Conditional content and changing collections legitimately insert, remove, or move nodes.

Identity in collections

Without explicit keys, repeated executions of the same call site are distinguished by execution order:

@Composable
@AtlasComposable
fun MessageList(messages: List<Message>) {
    Group {
        messages.forEach { message ->
            Text(message.title)
        }
    }
}

Inserting at the front changes what every position means. Give each item domain identity:

import androidx.compose.runtime.key

@Composable
@AtlasComposable
fun MessageList(messages: List<Message>) {
    Group {
        messages.forEach { message ->
            key(message.id) {
                Text(message.title)
            }
        }
    }
}
Diagram
flowchart LR
    subgraph Before
      A1[key A] --> NA[node A]
      B1[key B] --> NB[node B]
    end
    subgraph AfterInsert
      X[key X] --> NX[new node X]
      A2[key A] --> NA2[same node A]
      B2[key B] --> NB2[same node B]
    end
    NA -. retained .-> NA2
    NB -. retained .-> NB2

Keys must be stable and unique among executions of the same key call site; combine values when one field is insufficient. Other key call sites may safely reuse the same values.

remember is composition memory

Diagram
flowchart LR
    CallPosition --> Slot[slot-table location]
    Slot --> Remembered[remembered value]
    CallPosition --> NodeIdentity[emitted node identity]

remember stores values in the composition, not in the Atlas node. A node can retain visual properties while remember retains component-local state. They are coordinated by composition identity but remain different representations.

Property versus structural change

App changeTypical Runtime resultAtlas consequence
count changesupdater changes a propertyexisting text node becomes dirty
if branch appearsnode insertionparent layout invalidated
branch disappearsnode removaldetached node loses parent
keyed item reordersnode moveordering changes; identity retained
key changesremove + insertold identity is discarded

Observe identity in a test

val count = mutableStateOf(0)
host.setContent {
    Counter(count = count.value, onIncrement = { count.value++ })
}
host.advanceUntilIdle()
val firstNode = root.findText("Count: 0")

count.value = 1
host.advanceUntilIdle()

val secondNode = root.findText("Count: 1")
assertSame(firstNode, secondNode)

advanceUntilIdle is an Atlas test-harness operation, not kotlinx.coroutines.test.advanceUntilIdle() or a Compose Runtime API. Its contract is to deliver pending snapshot apply notifications, provide required Compose frame-clock opportunities, await recomposition and apply, and—when frame output is asserted—await Atlas rendering.

Visual checkpoint

Draw keyed items A, B, and C. Insert X at the front, remove B, then swap A and C. Use arrows to show which node identities survive each operation.

Review questions

  1. What connects a state read to later invalidation?
  2. How does remember differ from a retained Atlas node?
  3. When should a list item use key?

Recap

State invalidates composition work.
Call sites, execution order, and keys determine identity.
Stable identity enables in-place node updates.
Structural changes remain normal and necessary.

Sources

Finished this lesson?

Your progress stays only in this browser.