Jetpack Compose Slot Table
If you've ever asked yourself "how does Compose know what to redraw when state changes?" — the answer is a data structure called the Slot Table. It sounds scary, but the idea behind it is actually really simple once you strip away the jargon.
Let's build it up from scratch.
The Problem Compose Had to Solve
Old-school Android Views work by comparing two trees every time something changes — the old view tree and the new one — and figuring out the differences. This is called diffing, and it's expensive.
Compose said: "What if we never diffed anything? What if we just remembered what we did last time, and only redo the parts that actually changed?"
That "memory" is the Slot Table.
Think of It Like a Notebook, Not a Tree
This is the part that trips people up. You'd expect Compose to store your UI as a tree (because that's how it looks on screen — a parent with children). But it doesn't.
Instead, Compose writes everything down in the order it happens, like a diary entry:
@Composable
fun Profile(name: String) {
Text("Hello")
Text(name)
}
Compose doesn't see "a Profile box containing two Text boxes." It sees a sequence of events:
Entered
ProfileCalled
Text("Hello")Called
Text(name)Exited
Profile
That's exactly what gets written into the slot table — one line after another, like a notebook, not a tree.
What's Actually Sitting in a "Slot"?
A slot is just one entry in that notebook. It can hold:
A value from
remember { }A piece of state
A reference to a UI node
Info about which composable was called
And a group is the boundary marker around a composable call — a "start" and "end" bookmark so Compose knows where one function's stuff begins and ends.
So really:
Slot table = a flat list of entries, with start/end bookmarks (groups) showing which entries belong to which composable.
A Bigger Example
@Composable
fun Counter(count: Int) {
val doubled = remember { count * 2 }
Text("Count: $count")
Text("Doubled: $doubled")
}
Here's roughly what gets written into the notebook the first time this runs:
Position 0 → START group: Counter
Position 1 → slot: remember (doubled)
Position 2 → START group: Text("Count")
Position 3 → END group
Position 4 → START group: Text("Doubled")
Position 5 → END group
Position 6 → END group: Counter
Notice: it reads top to bottom, like a script — not like a tree diagram. The "tree shape" only exists visually on your screen; underneath, it's one long, ordered list.
So What Happens When count Changes?
This is the payoff. When count changes, Compose does not throw away the notebook and start over. Instead:
It marks
Counteras "dirty" (needs a re-check).It re-runs the
Counterfunction.It walks through the same notebook positions in the same order.
At each position, it asks: "Do I already have something here? Can I reuse it, or does it need updating?"
For remember { count * 2 } specifically:
Compose looks at position 1.
Sees there's already a value there.
Reuses it — unless the thing being remembered depends on something that changed.
No rebuilding. No comparing two trees. Just walking a list and reusing what hasn't changed. That's why Compose is fast.
Two Different Things People Mix Up
Invalidation = "this part might have changed, flag it."
Recomposition = "actually re-run the code for the flagged part."
The slot table is what lets Compose separate these two steps — it knows exactly which notebook entries are flagged, so it only re-runs those, not everything.
Where It Gets Dangerous: Changing Order
Here's the classic mistake:
if (showDetails) {
Text("Details")
}
Text("Footer")
When showDetails is true, the notebook looks like:
0 → START
1 → Text("Details")
2 → Text("Footer")
3 → END
When showDetails is false, the notebook looks like:
0 → START
1 → Text("Footer")
2 → END
See the problem? Text("Footer") used to live at position 2. Now it lives at position 1. As far as the slot table is concerned, a completely different composable now sits where "Footer" used to be. Compose can't just reuse things — it has to figure out the structural change, which is more expensive and can even cause state to reset unexpectedly.
This is exactly why key { } and movableContentOf exist — they let you tag content with a stable identity so Compose can track it correctly even when its position in the notebook shifts around.
Common Misconceptions, Cleared Up
=> "The slot table is basically the UI tree." No — it's a recording of what got called and in what order, not a tree structure.
=> "remember stores things globally, like a global variable." No — it stores the value at one specific notebook position. Move that call around, and it may not find its old value anymore.
=> "Recomposition means everything re-runs." No — only the parts that got marked "dirty" re-run. Everything else is skipped.
Why Should You Actually Care About This?
Once this clicks, a bunch of "senior-level" Compose questions stop being magic:
Why does my state randomly reset when I reorder composables? → Slot position changed.
Why does
key {}fix my list item bugs? → It gives items a stable identity independent of position.Why is Compose faster than the old View system for frequent updates? → No tree diffing, just notebook position lookups.
One More Example — Let's Trace It Fully
Let's use something closer to a real screen, so you can see the whole lifecycle: slot table creation → what's stored → what happens when state changes.
@Composable
fun CartScreen(itemCount: Int) {
val isEligibleForFreeDelivery = remember(itemCount) { itemCount >= 3 }
Text("Items in cart: $itemCount")
if (isEligibleForFreeDelivery) {
Text("You've unlocked free delivery! 🎉")
}
Text("Proceed to checkout")
}
Think of a Swiggy/Zomato-style cart screen: it shows the item count, a free-delivery banner if you've crossed the threshold, and a checkout line.
Step 1: First Composition — How the Slot Table Gets Built
The very first time CartScreen runs, Compose executes the function top to bottom and writes one notebook entry per thing it encounters. Let's say itemCount = 3:
Position 0 → START group: CartScreen
Position 1 → slot: remember(itemCount) → isEligibleForFreeDelivery = true
Position 2 → START group: Text("Items in cart: 3")
Position 3 → END group
Position 4 → START group: if-block (isEligibleForFreeDelivery)
Position 5 → START group: Text("free delivery unlocked")
Position 6 → END group
Position 7 → END group: if-block
Position 8 → START group: Text("Proceed to checkout")
Position 9 → END group
Position 10 → END group: CartScreen
A few things worth noticing about how this table got built:
Compose didn't "design" this layout upfront. It literally just executed your function line by line, and each line created one more notebook entry, in order.
The
remember(itemCount) { ... }call got its own slot (position 1) tied to the keyitemCount. Compose also silently stores the key (3) next to it, so it can later check "did this key change?"The
ifblock itself becomes a group (position 4–7) — even conditionals get a group, because Compose needs a boundary to know "this chunk may or may not exist depending on a condition."
Step 2: Recomposition — itemCount Changes from 3 to 5
Now say the user adds two more items, so itemCount becomes 5. Compose does not throw away the table and rebuild from scratch. Here's the walk:
Invalidation: Because
CartScreenreadsitemCount(an observed state), the group forCartScreen(position 0–10) gets marked dirty.Re-execution starts: Compose re-runs the
CartScreenfunction body.Position 1 check (
remember): Compose sees the keyitemCountused to be3, now it's5. Since the key changed, the remembered lambda re-runs:isEligibleForFreeDeliveryis recalculated → stilltrue(5 ≥ 3). The old value at position 1 gets replaced.Position 2–3 (
Text("Items in cart...")): This group's input (itemCount) changed, so thisTextrecomposes with the new string.Position 4–7 (the
ifblock): SinceisEligibleForFreeDeliverywastruebefore and is stilltrue, this group still exists in the same shape — Compose reuses its structure and just checks whether the innerTextneeds updating (it doesn't, since the string is hardcoded — so it's skipped entirely).Position 8–9 (
Text("Proceed to checkout")): Nothing this composable depends on has changed, so Compose skips re-running it altogether.
End result: out of everything in CartScreen, only the remember value and the item-count Text actually did any real work. Everything else was walked past and reused as-is.
Step 3: The Case Where It Gets Expensive — itemCount Drops to 2
Now say the count drops from 5 to 2. This time isEligibleForFreeDelivery flips from true to false. Here's where it's a bit more costly:
Position 1 (
remember) recalculates: key changed (5 → 2), so the lambda re-runs → nowfalse.Position 2–3 recomposes with the new count text, same as before.
Position 4–7 (the
ifblock): last time, this group existed with aTextinside it. This time, the condition isfalse, so the group's content needs to be removed entirely — the "free delivery unlocked"Textand its slots get torn down, not just skipped.Position 8–9 (
Text("Proceed to checkout")) is unaffected either way, so it's skipped.
This is the more expensive path: instead of just reusing or lightly updating a slot, Compose has to actually add/remove groups from the table, because the shape of what got called changed, not just the values inside it.
Compose UI = a sequence of function calls. Slot table = the notebook that remembers those calls. Recomposition = re-reading the notebook and only rewriting the pages that changed.
That's the whole idea.
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Get the latest updates
Join our newsletter and never miss an article.