Most Compose bugs I see in code review share one root cause: someone treated a @Composable function like a view factory instead of a function whose call position is its identity. Once you understand the slot table, state loss on reorder, remember that "doesn't remember", and mysterious skipping stop being mysterious.
This post covers what the slot table is, how recomposition walks it, and the failure modes you will hit in production.
What the slot table is
The slot table is Compose’s runtime record of your composition. Where the View system gives you a tree of heap objects, Compose gives you a linear, array-backed structure that is read and written in the same order your composable functions execute.
Take this code:
@Composable
fun Screen() {
Column {
Text("Hello")
val counter = remember { mutableStateOf(0) }
Text("World")
}
}
The mental model is a flat sequence of groups and data:
Nesting is encoded by position and group size, not by parent pointers you chase at runtime. That is what makes the walk cheap and cache-friendly.
The mental model is slightly wrong (on purpose)
The single-array picture is the right way to think, but the real SlotTable in androidx.compose.runtime splits the data across two parallel arrays:
-
groups: IntArray: a fixed number of ints per group (key, flags and node count, parent anchor, size, data anchor). -
slots: Array<Any?>: the payload: remembered values, recompose scopes, previous values of non-constant parameters (for skipping checks), nodes, and so on.
One correction to the simplified picture above: "Hello" and "World" are compile-time constants. The compiler marks such arguments as static in $changed, so they are not stored in a slot. A parameter that varies at runtime, such as Text(name), is stored so the next recomposition can compare it. The linear diagram is a teaching model; group descriptors and slot payloads actually live in separate address spaces.
Both are gap buffers. Details change between runtime versions, so treat the field layout as illustrative rather than as an API.
Why gap buffers? Composition is mostly “edit near where I last edited”. A gap buffer keeps a hole at the last edit point, so insertions and removals there are close to free. A distant edit costs one array copy to move the gap.
Where the keys come from
You never write group keys. The Compose compiler plugin rewrites each composable to take an extra Composer and a $changed bitmask, and it wraps bodies in group calls with an Int key derived from the source location. Conceptually, Screen becomes:
// Conceptual output. Do not rely on exact shape.
fun Screen(composer: Composer, changed: Int) {
composer.startRestartGroup(/* key = 0x1A2B... */)
// body, with startReplaceGroup/startMovableGroup around
// conditionals, loops, and key { } blocks
composer.endRestartGroup()?.updateScope { c, _ -> Screen(c, changed or 1) }
}
Three group flavors matter in practice:
-
Restart groups wrap composables that return
Unit. They are the unit of recomposition scope. -
Replaceable groups wrap branches (
if/when). A different branch means a different key, so old slots are discarded. -
Movable groups come from
key(x) { }. They carry an object key, so the runtime can find and move a group instead of destroying it.
How recomposition walks the table
During recomposition the composer keeps a cursor into the table and does not rebuild the tree. At each group it asks:
In words:
- Same key, inputs unchanged: skip. The cursor jumps past the group using its recorded size, so none of its children run.
- Same key, inputs changed: re-run the body, reusing the slots already stored for children.
- Different key: look for a movable group with that key. If found, move it and keep its state. Otherwise, insert a new group, run the code, and remove the old slots.
-
rememberslot: compare the remember keys. Unchanged returns the cached value without running your lambda.
Skipping depends on the compiler comparing each parameter to the value stored in the slot table from the last composition. Stable types are compared with equals. Since Kotlin 2.0.20, strong skipping mode is on by default and also skips with unstable parameters, comparing them by instance (===). Check your compiler version before reasoning about why something did or did not skip.
Position is identity
Here is the sentence that explains most state bugs: the position in the slot table is the identity. Two remember {} calls at different positions get different slots, because they are different call sites in execution order.
That is also the failure mode.
Failure mode 1: Loops without keys
@Composable
fun ExpandableList(items: List<Item>) {
Column {
items.forEach { item ->
// BUG: identity is the loop iteration order, not item.id
var expanded by remember { mutableStateOf(false) }
ItemRow(item, expanded) { expanded = !expanded }
}
}
}
The user expands B, then deletes A. Slot 0 still holds false, slot 1 still holds true:
Fix it by giving each iteration a stable identity:
@Composable
fun ExpandableList(items: List<Item>) {
Column {
items.forEach { item ->
key(item.id) {
var expanded by remember { mutableStateOf(false) }
ItemRow(item, expanded) { expanded = !expanded }
}
}
}
}
I kept plain remember so the only change is key(item.id). If the expanded state must also survive recreation or process death, swap in rememberSaveable. Its default key already incorporates the enclosing key(item.id) group, so no extra key is needed, but the value must be Bundle-saveable.
In lazy lists, the equivalent is items(items, key = { it.id }). Make sure keys are unique and stable. Duplicate keys crash LazyColumn at runtime, and key {} with duplicates silently produces ambiguous matching.
Failure mode 2: Keys that are not really keys
key(index) or key(item.hashCode()) on mutable data defeats the purpose. Index is just position again, and a hash that changes when the item mutates will tear down state on every edit. Use a stable domain ID.
Failure mode 3: Conditional branches silently reset state
@Composable
fun Profile(isEditing: Boolean, user: User) {
if (isEditing) {
var draft by remember { mutableStateOf(user.name) }
EditForm(draft) { draft = it }
} else {
ReadOnlyForm(user)
}
}
Each branch has its own group, so leaving the isEditing branch discards draft. Often that is what you want; the bug is expecting it to survive. If state must outlive the branch, hoist it above the if, or store it in a ViewModel or rememberSaveable higher up.
Failure mode 4: remember with the wrong keys
@Composable
fun UserBadge(userId: String, repo: UserRepo) {
// BUG: stale if userId changes; the slot already holds a value
val user = remember { repo.cachedUser(userId) }
// Correct: re-run when the input changes
val fresh = remember(userId) { repo.cachedUser(userId) }
}
Without keys, the lambda runs once for the group’s lifetime. Pass every input the lambda captures as a key.
Failure mode 5: Stale lambdas captured by long-lived effects
@Composable
fun Timer(onTimeout: () -> Unit) {
// BUG: effect captures the first onTimeout forever
LaunchedEffect(Unit) {
delay(5_000)
onTimeout()
}
}
@Composable
fun TimerFixed(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) {
delay(5_000)
currentOnTimeout()
}
}
rememberUpdatedState keeps one slot whose value is refreshed each recomposition, so the long-lived coroutine always reads the latest lambda.
Failure mode 6: Remembered objects leak what they capture
Slots hold strong references until their group leaves the composition. Remembering something that captures an Activity or a large Bitmap ties its lifetime to that group. For resources with cleanup, implement RememberObserver (or use DisposableEffect) so onForgotten runs when the slot is dropped.
// Common pattern: fine for normal lifecycles
@Composable
fun rememberPlayer(context: Context): ExoPlayer {
val appContext = context.applicationContext
val player = remember { ExoPlayer.Builder(appContext).build() }
DisposableEffect(player) { onDispose { player.release() } }
return player
}
This has a gap. If composition is abandoned after remember runs but before the changes are applied (for example, a cancelled or interrupted composition), the DisposableEffect never enters, so onDispose never runs and the player leaks. Wrapping the resource in a RememberObserver covers forgotten and abandoned slots:
private class PlayerHolder(context: Context) : RememberObserver {
val player: ExoPlayer = ExoPlayer.Builder(context).build()
override fun onRemembered() = Unit
override fun onForgotten() = player.release()
override fun onAbandoned() = player.release()
}
@Composable
fun rememberPlayer(context: Context): ExoPlayer {
val appContext = context.applicationContext
return remember { PlayerHolder(appContext) }.player
}
Failure mode 7: Expecting remember to survive recreation
The slot table belongs to the composition. When the Activity is recreated on a configuration change, the composition is rebuilt and every remember slot starts empty. Use rememberSaveable for small UI state, and a ViewModel for anything heavy or asynchronous.
Failure mode 8: Side effects during composition
Composition can run more than once, be abandoned, or run in a different order than you expect. Writing to state or doing I/O in the body is how you get infinite recomposition loops and flaky tests:
@Composable
fun Bad(vm: VM) {
vm.track("screen_shown") // runs on every recomposition
}
@Composable
fun Good(vm: VM) {
LaunchedEffect(Unit) { vm.track("screen_shown") }
}
Debugging checklist
- Layout Inspector recomposition counts show which composables run and which are skipped.
-
Suspect state loss? Look for a branch, a loop, or a
movableContentOfboundary above theremember. -
Suspect missed skipping? Check parameter stability and the Compose compiler reports (
-P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=...). - Reordering a collection? Every item needs a stable key.
Takeaways
- The slot table stores the composition as groups and data in a linear, gap-buffered structure; the real implementation uses parallel arrays, and constants are not stored in slots.
- Recomposition is a cursor walk: matching keys are skipped or re-run, mismatched keys move or replace.
-
Position is identity.
rememberis scoped to a call site in execution order, so keys, branches, and loops determine whether your state survives. - Most “Compose bugs” are identity bugs: add
key(), hoist state, or key yourremember.
📱 New to Jetpack Compose? Start Here
If you’re coming from the classic Android View system — XML layouts, findViewById, RecyclerView adapters — and want a clear, no-jargon path into Jetpack Compose, I recommend "Learning Jetpack Compose: Your Practical Guide from the Android View System to Modern Declarative UI." Every chapter starts from something you already know and shows its Compose equivalent side by side, building up to a full capstone: a real Notes app. Once you're comfortable and want to go deeper — the Composition tree, recomposition internals, the runtime itself — "Mastering Jetpack Compose Internals" is the natural next step.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover
Originally published on Medium.





Top comments (0)