← All articles

Compose: Component Identity, the Slot Table, and State Lifecycle


A reference document covering how Jetpack Compose tracks composable identity, how remember and key() interact with the slot table, and how to reason about state reset behavior. Written as a revision aid — assumes you’ve used Compose but want the underlying model.


Table of contents

  1. The slot table — Compose’s memory
  2. Group keys and positional identity
  3. How remember actually works
  4. Why parameter changes don’t reset state
  5. The three (and only three) ways state resets
  6. The state-management toolbox
  7. Loop iterations and the key() rule
  8. The “conditional structure vs conditional arguments” rule
  9. Events vs values — bridging button clicks to state
  10. remember and key() reset state — they don’t store it
  11. Decision framework — which tool to reach for
  12. Worked examples
  13. Interview-grade summary
  14. Footgun checklist

1. The slot table — Compose’s memory

Composable functions don’t return a tree of objects like React does with the virtual DOM. Instead, the Compose runtime maintains a single flat data structure called the slot table — a gap buffer of slots that stores everything Compose needs to remember between recompositions.

A slot can hold:

Composition is the act of walking this slot table — comparing what your composable functions want to write versus what’s already there, and emitting changes.

The slot table is the identity. A “composable instance” is really just a contiguous range of slots inside the table. There is no heap object that represents a Counter instance the way a View instance exists.


2. Group keys and positional identity

The Compose compiler plugin transforms every @Composable function. At every call site it injects code roughly equivalent to:

composer.startReplaceableGroup(<sourceHashKey>)
// body of the composable
composer.endReplaceableGroup()

<sourceHashKey> is computed at compile time from the file and a stable index of the call site within that file. Think of it as the compiler stamping every call site with an invisible label.

Consequences

A call’s full identity is the triple:

(source-position-hash, optional-explicit-key, call-order-among-siblings)

This triple is what the slot table uses to address slots. Identity is structural and lexical — it has nothing to do with the function being called or the values being passed.

Worked example

@Composable
fun Screen() {
    Counter("A")     // call site 1 → key K1
    Counter("B")     // call site 2 → key K2
    if (cond) {
        Counter("X") // call site 3 → key K3 (in 'if' branch)
    } else {
        Counter("X") // call site 4 → key K4 (in 'else' branch — DIFFERENT identity)
    }
}

Even though call sites 3 and 4 invoke Counter with identical arguments, they are not the same identity. Toggling cond tears down K3’s group and opens K4’s group — any state inside that Counter resets.


3. How remember actually works

remember { initializer } compiles roughly to:

composer.cache(invalid = false) { initializer() }

The cache operation walks to the slot at the current position and:

That’s the entire algorithm.

Critical implications

Keyed remember

val x = remember(key1, key2) { compute() }

Compiles to a slot that stores both the keys and the value. On each recomposition, Compose compares stored keys to the current keys:

The lambda still doesn’t observe anything magically — sensitivity to a parameter only exists if you list it as a key.

MutableState and slot semantics

var count by remember { mutableStateOf(0) }
count++   // does NOT replace the slot

remember caches the MutableState holder, not the integer. count++ calls state.value = state.value + 1 on the same holder object — the slot is unchanged; only the holder’s .value field flipped. That mutation is what triggers recomposition for readers of count.

This is why var count by remember { mutableStateOf(0) } works correctly even when the parent passes new arguments: the slot persistently holds the same state container that you can write to as much as you want.


4. Why parameter changes don’t reset state

A common misconception: “if I pass a different parameter, remember’s initializer should re-run with the new value.”

This is wrong, and it’s wrong by design.

The contract

remember { f() } says: “Call f() exactly once for this slot, the first time it’s reached. After that, ignore f() and return whatever I stored.”

Parameters of the enclosing composable are not part of the slot’s identity unless you explicitly list them as keys. Compose has no way to know which parameters should invalidate which remember calls — only you do.

Worked example

@Composable
fun Greeting(name: String) {
    val message = remember { "Hello, $name!" }
    Text(message)
}

The screen still says “Hello, Alice!” forever.

Fix:

val message = remember(name) { "Hello, $name!" }

Now name is part of the slot’s identity. Changing it invalidates the slot.

Why this is a feature

If remember reset whenever a parameter changed, no composable could keep state across recomposition — because parameters change on every state-driven update. State surviving parameter flow is the entire reason remember exists. The minority case (“I want this to reset on input change”) needs an explicit opt-in.

Mental model

remember is a cache, not a reactive expression. It does not “depend on” the surrounding parameters. The lambda is the “compute on miss” function:

remember        { ... }   // key = (slot-position)
remember(a)     { ... }   // key = (slot-position, a)
remember(a, b)  { ... }   // key = (slot-position, a, b)

Cache hit → return stored value. Cache miss → run lambda, store result. No observation, no dependency tracking.

The same logic explains LaunchedEffect:

LaunchedEffect(Unit)   { fetch(userId) }   // launches once, never restarts
LaunchedEffect(userId) { fetch(userId) }   // restarts when userId changes

And DisposableEffect, produceState, etc. They all follow the same rule: the slot has a key; the key is whatever you tell it to be.


5. The three (and only three) ways state resets

State persists for as long as its enclosing group exists in the slot table. The group is removed in exactly three scenarios:

(A) An if/when enters a different branch

when (screen) {
    is ScreenA -> ComponentA()    // group K_A
    is ScreenB -> ComponentB()    // group K_B
}

Switching from ScreenA to ScreenB removes K_A’s slots (running disposers, freeing nodes) and opens K_B’s slots fresh.

(B) The composable simply isn’t called

if (visible) {
    Tooltip()   // group lives only when visible == true
}

When visible flips false, Tooltip() is no longer invoked. Its group leaves the composition; its slot range is freed; effects dispose.

(C) A key(...) block’s key value changes

key(sessionId) {
    Login()
}

Changing sessionId is treated as a different group identity. The old group is torn down, a new one opens.

What does NOT cause a reset

If you want a reset, you must produce one of A/B/C. Otherwise, state survives.


6. The state-management toolbox

Four mechanisms exist for sensitivity. Each is the right answer for a specific need:

You want sensitivity to… Use
A specific parameter value remember(param) { ... }
An item’s identity in a loop key(item.id) { ... } (around the call)
An external “reset” trigger Hoist state ➝ direct write or key(counter) { ... }
Nothing — just persistent state remember { ... } (default)

remember(key) vs key(key) { ... }

Both compare keys to detect changes. They differ in scope:

If Composable() has a single piece of state, prefer remember(key) — surgical. If it has many remembers and effects that should all reset together, key() is more economical — one signal, all slots.

State hoisting

Move state to the parent (or a ViewModel) and pass it down as parameters; pass mutations back up as callbacks:

@Composable
fun Stateful() {
    var x by remember { mutableStateOf(0) }
    Stateless(x = x, onChange = { x = it })
}

@Composable
fun Stateless(x: Int, onChange: (Int) -> Unit) {
    // pure function of (x, onChange) — no remember
}

Hoisting is the architectural fix. The other tools are escape hatches for when hoisting isn’t practical (third-party composables with internal state, deeply nested view-local logic, etc.).


7. Loop iterations and the key() rule

Inside a loop, the compiler stamps one group key on the loop body. The runtime distinguishes iterations by call order.

todos.forEach { todo ->
    TodoRow(todo)        // ONE call site, executed N times
}

Slot table stores per-iteration state at positions 0, 1, 2… The slot table doesn’t know about todo.id — it only knows iteration index.

Failure mode: reorder/insert/remove from middle

If todos reorders, the slot at position 0 is now associated with a different Todo. Internal state (e.g. an “is this row in edit mode” flag inside TodoRow) sticks to the position, not to the data. Edit state visibly jumps from one row to another.

Fix

todos.forEach { todo ->
    key(todo.id) {
        TodoRow(todo)
    }
}

Now each iteration’s group identity includes todo.id. Compose can match groups across recompositions by id and move slot ranges to follow the data instead of re-binding stale state to new items.

LazyColumn’s key parameter

LazyColumn {
    items(todos, key = { it.id }) { todo ->
        TodoRow(todo)
    }
}

This is the same mechanism, exposed as a first-class API. Always pass key for any list that can reorder, insert in the middle, or delete. Treat its absence as a latent bug.

When you can skip the key

If the list is append-only and never reorders, the iteration index is effectively a stable identity. Removing the trailing item also works — only the last slot is freed. Anything beyond that requires a real key.


8. The “conditional structure vs conditional arguments” rule

When two code paths render the same composable with different inputs, write one call site with conditional arguments, not two call sites in different branches.

❌ Antipattern

if (isActive) {
    Button(text = "Save", color = Blue) { onSave() }
} else {
    Button(text = "Save", color = Gray) { onSave() }
}

Two call sites = two group keys = two slot ranges. Toggling isActive tears down one group and builds the other. Every remember, LaunchedEffect, animation state, scroll position, focus state inside the Button resets.

✅ Correct

Button(
    text = "Save",
    color = if (isActive) Blue else Gray,
) { onSave() }

One call site = one group = one slot range. Recomposition just walks new parameters through. Compose’s skipping optimization may even bail out early if all parameters are @Stable and unchanged.

What breaks under the antipattern

These are all consequences of group teardown, easy to miss in code review:

When the antipattern is correct

The rule has a precise inverse: when you genuinely want a fresh instance, branches are correct.

The rule is: don’t use code structure to express what is really a parameter.


9. Events vs values — bridging button clicks to state

Compose’s reactivity is value-driven, not event-driven. remember, LaunchedEffect, derivedStateOf — all of them work by comparing values across recompositions. There is no built-in concept of “an event fired.”

A button click is an event — a one-time signal that fires and disappears. To make Compose’s value-based machinery react to it, you must convert the event into a value change.

Pattern: synthetic counter

@Composable
fun Screen() {
    var resetTrigger by remember { mutableStateOf(0) }
    key(resetTrigger) {
        Counter()                                     // tears down on bump
    }
    Button(onClick = { resetTrigger++ }) { Text("Reset") }
}

resetTrigger++ translates “click happened” into “value is now different.” key(resetTrigger) reacts to the value change by tearing down Counter.

When this is fine

When the state being reset is legitimately view-local but needs an external reset signal — third-party WebView wrapped in AndroidView, complex animation state inside an opaque component, etc.

When this is a smell

When resetTrigger has no domain meaning — it’s just “a number that changes when the user clicks.” That counter exists only to satisfy remember’s value-comparison machinery. The clean answer is to hoist the state so the click handler can write to it directly:

@Composable
fun Screen() {
    var count by remember { mutableStateOf(0) }
    Counter(count = count, onIncrement = { count++ })
    Button(onClick = { count = 0 }) { Text("Reset") }
}

No counter, no key(). The state lives where the button that mutates it lives.

The rule

State and the events that mutate it should live at the same level. When they do, mutation is just an assignment. When they don’t, you need a bridge — and the bridge has a meaningful or meaningless flavor:


10. remember and key() reset state — they don’t store it

This is the most under-appreciated distinction in Compose state management.

remember(key) { ... } and key(...) { ... } are mechanisms for resetting state when something changes. They have no memory of values they’ve discarded.

var answer by remember(question.id) { mutableStateOf("") }

When question.id changes from 1 to 2, the slot is invalidated and a new empty state is created. The old answer for question 1 is gone forever. If the user navigates back to question 1, the key is “1” again, but Compose doesn’t archive what was in the slot before — it just sees a key change and re-runs the initializer.

To preserve state across navigation, use storage, not keying

Hoist the state to the parent and store it in a structure that can hold multiple values:

val answers = remember { mutableStateMapOf<Int, String>() }
val current = questions[index]

QuestionCard(
    question = current,
    answer = answers[current.id].orEmpty(),
    onAnswerChange = { answers[current.id] = it },
)

Now answers live in a map keyed by question id. The user can navigate freely; each question’s answer is looked up from the map; edits go back to the map. Nothing is invalidated; everything is stored.

The conceptual contrast

Need Tool
“Forget the old, start fresh” remember(key), key()
“Remember per-id, recall on demand” Hoisted mutableStateMapOf, Map<Id, State>
“Survive process death and config change” rememberSaveable (within group lifecycle), or ViewModel/DataStore

key() is a reset mechanism. A map is a storage mechanism. They are different problems.


11. Decision framework — which tool to reach for

When you have a state-reset / state-survival question, work through this in order:

  1. Is the state read or written by the parent or a sibling? → Hoist it. State belongs at the highest level that needs to read or write it.

  2. Is the click handler / mutation source in a different composable from the state? → Hoist it.

  3. Should state reset when a specific parameter changes?remember(param) { ... } — surgical, single slot.

  4. Should state reset based on item identity in a loop?key(item.id) { ... } — around the iteration body.

  5. Should state reset based on an external “reset” event, where the state is genuinely view-local?key(counter) { ... } with a synthetic counter, or hoist if practical.

  6. Should state survive across navigation, not reset? → Hoist + a storage structure (Map, List, ViewModel). remember(key) and key() will not do this.

  7. Should state survive process death / configuration change?rememberSaveable (per-instance), or ViewModel + SavedStateHandle.

  8. None of the above — just need persistent state for the lifetime of the call site? → Plain remember { ... }. Default and fine.


12. Worked examples

Example A: The sticky preview cast

// Wrong
addFlashcard = {"", ""} as (String, String) -> Unit

This is not a no-op lambda — it’s a Pair<String, String> cast as a function reference. It will throw ClassCastException at runtime.

// Right
addFlashcard = { _, _ -> }

Lesson: a no-op for (A, B) -> Unit is { _, _ -> }, not a value cast.

Example B: The flashcard “Add another” frozen screen

@Composable
fun FlashCard(navigateToAddFlashCard: () -> Unit, ...) {
    var isAdding by remember { mutableStateOf(true) }
    // ... isAdding flips to false on submit
    Button(onClick = navigateToAddFlashCard) { Text("Add another") }
}

// Parent
navigateToAddFlashCard = { flashCardScreen = FlashCardScreen.AddFlashCard }

Bug: tapping “Add another” reassigns the same enum value. The when stays on the same branch, the same FlashCard group keeps its slots, isAdding = false survives. Screen looks frozen.

Fix options:

Lesson: reassigning a MutableState to its current value is not a state reset. None of A/B/C from §5 occurs.

Example C: The NumberInput that persists the typed number

NumberInput owns var field by remember { mutableStateOf("") } internally. The parent’s “Next number” button resets correctNumber and isAnswerCorrect, but the field slot inside NumberInput is unchanged — same call site, same slot. The TextField re-renders with stale text.

Fix: hoist field to GuessNumber, make NumberInput stateless, reset with field = "" on Next click.

Lesson: child-owned state is invisible to the parent’s reset logic. Hoist anything the parent needs to control.

Example D: The A() exercise

Five DisplayRandomValue calls; each owns a rememberSaveable random number. When the toggling Boolean flips:

Call Survives toggle? Why
Unconditional Blue yes Always called → same group → slot kept
Unconditional Cyan with value = a yes (number); display flips Same group, same slot; only parameter changes
if (a) Black(true) else Black(false) no Two different call sites in different branches; toggling tears down one and builds the other
if (a) Red no Conditional presence; false removes the group entirely
Unconditional Green yes Always called → same group

Lesson: structural changes (different branches, conditional dropout) cause resets. Same call site, no key() change → state survives.

Example E: repeat(n) { ... } counter

var num by remember { mutableStateOf(5) }
repeat(num) { DisplayRandomValue() }

Lesson: append-only growth at the end of repeat is safe without keys; insertions/deletions in the middle would be a positional-identity bug.

Example F: The Quiz problem

QuestionCard owns userAnswer; QuizScreen advances index with Next. Bug: Q1’s answer persists into Q2.

Three fixes ranked:

  1. remember(question.id) inside QuestionCard — works for forward navigation, but discards answers permanently. Going back to Q1 shows empty.

  2. key(question.id) { QuestionCard(...) } — same forward fix, same backward failure. Tearing down a group does not archive its slots.

  3. Hoist userAnswer to QuizScreen — works for forward, and supports going back if extended to a mutableStateMapOf<Int, String>().

Lesson: remember(key) and key() are reset mechanisms. To preserve state across navigation, you need storage, which means hoisted state and a multi-value structure.


13. Interview-grade summary

If asked “explain component identity in Compose,” the canonical answer:

Compose identifies composables by their position in the call tree, not by type or parameters. Under the hood, the compiler injects startGroup(sourceHashKey) / endGroup calls, where sourceHashKey is computed at compile time from the source location. The runtime maintains a slot table — a flat data structure where each group occupies a range of slots that store remember values, CompositionLocal providers, and node references.

remember { f() } writes its slot once on first composition and ignores the lambda afterward. State persists for as long as the enclosing group exists. Groups are removed in exactly three cases: an if/when enters a different branch, the composable stops being called, or a key(...) block’s key changes.

Parameters do not reset state — the slot has no opinion about parameters unless they’re explicitly listed as keys. This is by design: state is supposed to survive parameter flow, since recomposition’s whole point is to flow new data through the tree without losing what’s been remembered.

To control reset behavior: list parameters as remember(key) keys for surgical invalidation; wrap a subtree in key() for whole-group invalidation; or hoist state to the parent for explicit, structural control. remember(key) and key() are reset mechanisms — they discard state, they don’t store it. Storage requires hoisting and a structure that can hold multiple values.

Likely follow-ups


14. Footgun checklist

Quick scan of the most common Compose state bugs and their root cause in this model:


Appendix: One-line rules to memorize