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
- The slot table — Compose’s memory
- Group keys and positional identity
- How
rememberactually works - Why parameter changes don’t reset state
- The three (and only three) ways state resets
- The state-management toolbox
- Loop iterations and the
key()rule - The “conditional structure vs conditional arguments” rule
- Events vs values — bridging button clicks to state
rememberandkey()reset state — they don’t store it- Decision framework — which tool to reach for
- Worked examples
- Interview-grade summary
- 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:
- Values cached by
remember { ... } CompositionLocalproviders- References to underlying
LayoutNodes - Group markers (“this region of the table belongs to function X at call site Y”)
- Lifecycle observers (
RememberObserver,DisposableEffect’s effect)
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
- Two different calls to the same composable get different group keys (they sit at different lines).
- The same call site reached via different code paths — like two branches of
if/when— get different group keys (different lines). - A call site executed N times in a loop gets the same group key, but the runtime distinguishes iterations by call order.
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:
- Empty slot → run the lambda → store the result → return it.
- Filled slot → return the existing value, ignore the lambda completely.
That’s the entire algorithm.
Critical implications
- The lambda runs once per slot lifetime, not once per composition. After the slot is filled, it’s dead code.
rememberis keyed only by its position in the slot table. It doesn’t observe what its lambda reads.- A captured parameter inside the lambda is frozen at first-call time. It will not update.
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:
- All keys equal → return the stored value.
- Any key differs → treat the slot as empty, run the initializer, store the new value and new 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)
}
- First composition with
name = "Alice"→ slot empty → lambda runs, stores"Hello, Alice!". - Recomposition with
name = "Bob"→ slot full → returns"Hello, Alice!". Lambda never runs again.
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
- Parameters changing (covered in §4).
- A
MutableState’s.valuechanging (the slot still holds the sameMutableStateobject). derivedStateOfreads producing different results.- Recomposition itself.
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:
remember(key) { mutableStateOf(...) }— invalidates one slot when key changes.key(key) { Composable() }— invalidates the entire wrapped subtree’s group.
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:
TextFieldloses focus, IME dismisses- Animations snap back to initial value
- Coroutines in
LaunchedEffectcancel and restart (network requests fire again) AsyncImageplaceholder flashes back- Scroll position resets
rememberSaveablevalues regenerate
When the antipattern is correct
The rule has a precise inverse: when you genuinely want a fresh instance, branches are correct.
- Different concrete components:
if (loggedIn) Home() else Login() - Different
LayoutNodetypes:if (compact) Row { ... } else Column { ... } - You explicitly want state to reset
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:
- Meaningful bridge → it’s a domain concept:
key(currentUserId)when switching users. - Meaningless bridge → it’s a synthetic counter:
key(resetTrigger++). Smell.
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:
-
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.
-
Is the click handler / mutation source in a different composable from the state? → Hoist it.
-
Should state reset when a specific parameter changes? →
remember(param) { ... }— surgical, single slot. -
Should state reset based on item identity in a loop? →
key(item.id) { ... }— around the iteration body. -
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. -
Should state survive across navigation, not reset? → Hoist + a storage structure (
Map,List, ViewModel).remember(key)andkey()will not do this. -
Should state survive process death / configuration change? →
rememberSaveable(per-instance), or ViewModel +SavedStateHandle. -
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:
key(addCounter) { FlashCard(...) }withonClick = { addCounter++ }— workaround.- Hoist
isAdding,question,answerto the parent — clean fix.
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() }
- Increase 5 → 6: first 5 slots reused; 6th iteration is a fresh slot with a new random.
- Decrease 6 → 5: 6th slot freed; first 5 unchanged.
- 5 → 6 → 5 → 6: each new 6th iteration is a brand new random number — the previous 6th slot was freed when removed.
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:
-
remember(question.id)insideQuestionCard— works for forward navigation, but discards answers permanently. Going back to Q1 shows empty. -
key(question.id) { QuestionCard(...) }— same forward fix, same backward failure. Tearing down a group does not archive its slots. -
Hoist
userAnswertoQuizScreen— works for forward, and supports going back if extended to amutableStateMapOf<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)/endGroupcalls, wheresourceHashKeyis 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 storeremembervalues,CompositionLocalproviders, 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: anif/whenenters a different branch, the composable stops being called, or akey(...)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 inkey()for whole-group invalidation; or hoist state to the parent for explicit, structural control.remember(key)andkey()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
-
How does this differ from React? React diffs a virtual DOM and matches by element type and position with optional
keyprops. Compose has no diff phase — slots are positional via compiler-injected keys, so reconciliation is just slot-table walking. Both exposekeyfor the same reason: to override positional identity. -
What about effects?
LaunchedEffect,DisposableEffect,produceStateall live in slots. Their lifecycle is the slot’s lifecycle. They restart when their keys differ; they dispose when the group is torn down. -
Why doesn’t
remember’s lambda re-run when its captured parameter changes? Becauserememberis a positional cache, not a reactive expression. The lambda is the “compute on miss” function — it runs when the slot is empty. Captured parameters are frozen at first-call time. To makeremembersensitive to a value, list it as a key. -
What’s the cost of
key()? Tearing down a group runs disposers, releases nodes, frees slots. Building a new group allocates and runs initializers. It’s cheap relative to the View system but not free — overusingkey()defeats the slot-reuse optimization that makes Compose fast. -
Why does parameter sensitivity have to be opt-in? If
rememberreset on parameter change, no composable could have stable internal state across recomposition. Survival is the default because surviving parameter flow is what makes composables useful.
14. Footgun checklist
Quick scan of the most common Compose state bugs and their root cause in this model:
-
remember { mutableStateOf(initialParam) }with parameter only used in initializer → frozen at first call. Useremember(initialParam)or hoist. -
if (cond) Foo(x) else Foo(y)when only one argument differs → useFoo(if (cond) x else y). -
forEach { Item(it) }withoutkey()for any list that can reorder → state bleeds between items. Usekey(it.id) { Item(it) }. -
Reset by reassigning state to current value (e.g.
screen = Screen.Addwhen alreadyScreen.Add) → no-op recomposition. Needkey()or hoisted state. -
Child-owned state that the parent needs to control → hoist. The parent cannot reach into child slots.
-
Synthetic reset counter (
resetTrigger++) where state could be hoisted → smell. Hoist instead. -
remember(key)for navigation history → resets on navigate-away, lost forever. Use aMap<Id, State>for storage. -
LaunchedEffect(Unit)referencing a parameter → never restarts when parameter changes. Pass the parameter as a key. -
Wrapping
onValueChange = { onValueChange(it) }→ creates a fresh lambda every recomposition. Pass the function reference directly when possible. -
rememberSaveableinside a conditional that toggles → saved values are lost when the group is torn down. Saveable is for process death, not for normal composition lifecycle. -
Math.random().toInt()always returns 0 (truncates[0.0, 1.0)). Usekotlin.random.Random.nextInt(...).
Appendix: One-line rules to memorize
- Identity = position in the slot table, not type or parameters.
remember’s lambda runs once per slot lifetime, not per composition.- State resets only via branch change, removal from tree, or
key()change. - Parameters don’t invalidate state unless listed as explicit keys.
remember(key)andkey()reset state. Storage requires hoisting plus a multi-value structure.- For loops with reorderable data: always use
key(stableId). - Don’t use conditional structure to express what is really a conditional parameter.
- State and the events that mutate it should live at the same level.
- When tempted to invent a synthetic counter to bump, that’s a hint to hoist.