Jetpack Compose Under the Hood: How Recomposition, remember, and key() Really Work

Jetpack Compose looks deceptively simple. Write a composable. Pass some state. The UI updates. For a while, it feels almost magical.
Then you hit your first weird bug:
A list item “remembers” the wrong state after you insert a new item.
A
remembered value doesn’t reset when you expect it to.A composable recomposes way more (or way less) than you think it should.
That’s the moment you realise: Compose isn’t just a declarative UI toolkit. It has a runtime with its own model of identity, state, change, and execution. Once that model clicks, the “magic” becomes predictable.
This article is a practical, non-boring walkthrough of that mental model. We’ll connect:
Recomposition
Identity and the Slot Table
rememberandkey()Stability and skipping
Snapshots
Side effects (
LaunchedEffect,DisposableEffect,SideEffect)
By the end, you’ll have a single, usable picture of how Compose actually works under the hood.
Why Compose Needed a New Runtime
Traditional Android UI is largely imperative:
You have a XML or View hierarchy.
You find a
ViewwithfindViewByIdor binding.You call
setText(),setVisibility(), etc.You manually keep the UI in sync with your state.
Compose flips the problem:
Instead of telling the UI how to change, you describe what the UI should look like for a given state.
@Composable
fun UserScreen(user: User) {
if (user.isLoggedIn) {
Text("Welcome ${user.name}")
} else {
Text("Please log in")
}
}This code expresses a relationship: given this user, this is the UI that should exist.
Now imagine user.name changes.
Does Compose re-run the entire screen? It could, but that would be wasteful.
Instead, the runtime needs to answer:
What code previously produced this UI?
Which state did it read?
What changed?
Which parts need to run again, and which can be skipped?
That decision-making is the Compose runtime.
Recomposition: Selective Work, Not a Full Redraw
Recomposition is Compose selectively revisiting previously composed code because something that code depends on has changed.
It’s not “Compose redraws everything”. It’s “Compose figures out what actually needs to run again”.
Take a simple counter:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}When count changes:
Compose doesn’t rebuild the entire app.
It knows which parts of the composition read
count.It invalidates just those parts and re-executes them.
Think of the flow like this:
State change → invalidation → recomposition → execution → updated UI
Recomposition is selective work, not a blanket redraw.
Identity: How Compose Knows “This Is Still the Same Thing”
If you call the same composable twice:
UserRow(user1)
UserRow(user2)The function name is identical. How does Compose know which instance is which?
Call-site identity
Compose uses the call site—the position in the composition tree—as part of identity.
Each composable call gets a “slot” in the runtime’s internal structure.
That slot is identified by where it was called, not just by the function name.
This works great… until you have lists.
Lists and the key() problem
Consider:
users.forEach { user ->
UserRow(user)
}Now insert a new user at the top of the list.
All subsequent items shift position.
From Compose’s perspective, the slot positions change.
Remembered state can end up attached to the wrong item.
Solution: explicit keys.
users.forEach { user ->
key(user.id) {
UserRow(user)
}
}key() tells Compose:
“This chunk of UI is identified by
user.id. If that ID changes, treat it as a completely new subtree.”
Use key() when:
Items move around in a list.
You swap between different entities in the same composable position.
You want
remembered state to reset when the logical identity changes.
The Slot Table: Compose’s Runtime Notebook
The Slot Table is best understood as Compose’s internal notebook that tracks the composition.
Conceptually:
During composition, Compose records information about each composable call.
It stores this in a structure called the Slot Table.
On recomposition, it walks this table to match old and new calls.
Key points:
Slots are tied to position in the tree, not function names.
Order matters more than you think.
rememberstores its value in this table, associated with a specific slot.
So when you see:
val value = remember { expensiveCalculation() }think:
“Store
expensiveCalculation()in the slot for this call site. On recomposition, if this slot still exists and is valid, give me back the stored value instead of recalculating.”
remember: Tied to Composition Identity, Not to “The Function”
A common description: “remember survives recomposition.”
More precisely:
rememberassociates a value with a location in the composition and returns the existing value when that identity remains appropriate.
Consequences:
If the composable stays in the same position,
rememberkeeps the value.If the composable leaves the composition (e.g., inside an
ifthat becomes false), its slots are removed and the value is lost.If you move a
rememberinto a different branch or wrap it in a new composable, you change its identity and thus its lifetime.
A realistic bug pattern
@Composable
fun ItemEditor(item: Item) {
var draft by remember { mutableStateOf(item.note) }
// ...
}Later, you change item but keep the same composable position.
draftdoes not reset.Compose thinks it’s the same slot, so it reuses the old
remembered value.
To reset when the item changes, use key():
key(item.id) {
ItemEditor(item)
}Now, when item.id changes, Compose treats the inside as brand new, discarding old remembered state.
Stability: When Compose Decides to Do Nothing
One of Compose’s most powerful optimisations is skipping: not running a composable at all when nothing relevant has changed.
Flow:
Something changed → composable becomes relevant → inputs are considered → composable may recompose or may be skipped.
What is stability?
Stability is information that allows Compose to reason about whether a value can be considered unchanged for recomposition purposes.
@Stableand@Immutableare hints to the compiler/runtime.They don’t magically make things stable; they express guarantees about how the type behaves.
Example:
@Stable
class UserState {
var name by mutableStateOf("")
}Here, UserState is stable because:
Its properties are backed by
mutableStateOf, which Compose can track.Compose can safely skip composables that only read
UserStateif no observed state changed.
Mental model:
state → observation → invalidation → identity → stability → skip or recompose
Snapshots: The Invisible State System
It’s tempting to think of:
val count by remember { mutableStateOf(0) }
Text("$count")as “a mutable variable that triggers recomposition”.
In reality, Compose uses a snapshot system to manage observable state.
Key ideas:
When a composable reads state, that read is tracked as a dependency.
When state writes happen, Compose knows which parts of the composition depend on that state.
This read–write relationship is what drives invalidation and recomposition.
Snapshots also provide:
Controlled views of mutable state.
Consistency and isolation across threads and execution contexts.
So the chain is:
Composable reads
count→ dependency recorded.countchanges → snapshot marks dependent slots invalid.Compose schedules recomposition for those slots.
Side Effects: When UI Code Touches the Outside World
Composables describe UI, but apps also need to:
Launch coroutines.
Register listeners.
Interact with lifecycle.
Perform one-time actions.
Doing this directly in the body of a composable is dangerous because the composable can run many times. That’s why Compose provides effect APIs with their own lifecycles.
LaunchedEffect
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}Launches a coroutine when the composable enters composition.
Cancels when the composable leaves composition.
Restarts when the keys change.
Lifecycle:
Enters composition → coroutine starts.
Keys change → old coroutine cancelled, new one launched.
Leaves composition → coroutine cancelled.
DisposableEffect
DisposableEffect(lifecycleOwner) {
val observer = ...
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}Runs setup code.
Runs
onDisposewhen:The composable leaves composition, or
The keys change.
Use this for anything that needs cleanup: listeners, subscriptions, timers, etc.
SideEffect
SideEffect {
analytics.setUserProperty("screen", "Home")
}Runs after every successful recomposition (unless keyed).
Useful for pushing Compose state out to non-Compose systems.
Key distinction:
Composition and external effects have different lifecycles. Effects are your safe bridge between the two.
Putting It All Together: A Full Runtime Walkthrough
Let’s see all these ideas in one screen:
@Composable
fun UserScreen(userId: String) {
var refreshCount by remember { mutableStateOf(0) }
LaunchedEffect(userId) {
// Load user
}
Column {
UserHeader(userId)
Text("Refreshes: $refreshCount")
Button(onClick = { refreshCount++ }) {
Text("Refresh")
}
}
}What happens when you click “Refresh”?
State change
refreshCount++writes to amutableStateOf.Snapshot system participates
The write goes through Compose’s snapshot mechanism.Dependencies are considered
Compose knows which slots readrefreshCount(theText).Recomposition occurs
Only the affected portion of the composition is scheduled for recomposition.Identity preserves structure
Compose matches the new execution against the existing composition using call-site identity and keys.rememberretrieves its existing valuerefreshCount’s slot still exists, sorememberreturns the updated value instead of reinitialising.Stability influences skipping
Children whose relevant inputs haven’t changed may be skipped entirely.Effects have their own lifecycle
ChangingrefreshCountdoes not restartLaunchedEffect(userId)because its keys didn’t change.
The complete mental model:
State is read → dependency tracked.
State changes → invalidation.
Recomposition runs only what’s needed.
Identity + Slot Table preserve structure and remembered values.
Stability allows skipping.
Effects run with their own lifecycle rules.
Compose Runtime Cheat Sheet (Plain Language)
Use this as a quick reference when debugging or designing composables.
Composition
Compose executes composable code and builds the UI representation.Recomposition
Selectively revisiting previously composed code because relevant state or inputs changed.Identity
How Compose distinguishes one compositional instance from another (via call site and keys).Call site
Where a composable is invoked; part of its identity.key()
Explicit identity for chunks of UI, especially in lists or when swapping items.Slot Table
Internal structure that maintains information associated with the composition (includingremembered values).remember
Associates a value with a location in the composition so it survives recomposition while that identity remains appropriate.Snapshot
Compose’s state-management mechanism for tracking and coordinating state reads and writes.Stability
Information that lets Compose reason about whether a value can be considered unchanged, enabling skipping.Skipping
Avoiding execution of a composable when Compose can determine recomposition isn’t necessary.LaunchedEffect
Composition-aware coroutine launcher; lifecycle tied to composition, restarts on key change.DisposableEffect
LikeLaunchedEffect, but with explicit cleanup viaonDispose.SideEffect
Runs after successful recomposition; useful for pushing state out to non-Compose systems.
Debugging Checklist: Ask These Questions First
When something feels “off” in Compose, don’t jump straight to “which API should I use?”. Ask:
What changed?
Which state write triggered this?What was read?
Which composables depend on that state?Did identity change?
Did I move a composable, change anifbranch, or alter the tree structure?Is
rememberin the right place?
Am I accidentally tying remembered state to the wrong slot?Do I need
key()?
Are items moving around or swapping in the same position?Are my effects keyed correctly?
Should thisLaunchedEffectorDisposableEffectrestart on this input?Can this be skipped?
Are my types stable enough for Compose to skip work?
Once you start thinking in terms of state, identity, and runtime behaviour, Compose becomes a lot less magical—and a lot more understandable.
Want a Structured Path to Become Android Job-Ready?
If you already know Android but feel stuck with Modern Android, System Design, or interviews, you don’t need to start from scratch. You need the right structure, practice, and feedback.
Join 12 weeks Android Job Ready Bootcamp designed for senior android developer.
In 12 weeks, you’ll work on:
→ Jetpack Compose, Coroutines & Flow
→ Clean Architecture, MVVM & Hilt
→ Android Internals & Performance
→ LLD + HLD Mobile System Design
→ Machine Coding & Interview Preparation
→ CI/CD, Security & Production Practices
→ Real-world project
→ Mock interviews + Resume/LinkedIn review
→ 1:1 mentorship & referral support
The goal isn’t just to learn Android. It’s to become confident enough to clear Android interviews and build production-grade applications.
👉 Explore the Android Job Ready Bootcamp:
Master Android Job Ready Bootcamp
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.