Composable Lifecycle and Recomposition in Jetpack Compose
Composable Lifecycle and Recomposition in Jetpack Compose
Every composable function in Jetpack Compose follows a lifecycle defined by three events: entering the Composition, recomposing zero or more times, and leaving the Composition. Unlike the traditional Android View lifecycle with its many callback methods, this model is simpler in concept but more complex in its runtime implementation. The Compose runtime tracks each composable instance through a slot table, manages identity with positional keys, and uses snapshot state observation to determine exactly which composables need re-execution. Understanding these internals is essential for writing performant Compose code and diagnosing unexpected recomposition behavior. By the end of this lesson, you will be able to:
- Explain how the Compose runtime tracks composable instances through the slot table during initial composition and recomposition.
- Describe how positional memoization and call site identity determine composable lifecycle boundaries.
- Identify how the snapshot state observation system triggers targeted recomposition scopes.
- Trace how the Recomposer coordinates invalidation, scheduling, and re-execution of composable functions.
- Apply strategies for controlling recomposition granularity, including stability, keys, and scope boundaries.
The Slot Table and Composable Identity
The Composition stores the state of all composable instances in a data structure called the slot table. During initial composition, the Composer writes entries into this table in execution order. Each composable call site gets a unique identity based on its position in the call hierarchy, known as positional memoization.
The Composer class manages a cursor into the slot table. When a composable function executes, the Composer creates or updates a group in the table:
// Simplified representation of how the Composer tracks groups
@ComposeCompilerApi
fun startRestartGroup(key: Int): Composer {
startGroup(key)
// ...
return this
}
The compiler transforms every composable function to include startRestartGroup() and endRestartGroup() calls. These bracket the function body and define a restart scope, which is the unit of recomposition. The key parameter is a synthetic integer derived from the source location of the composable call. This means two calls to the same composable function from different call sites get different keys and are tracked as separate instances.
When a composable enters the Composition for the first time, the Composer is in insert mode. It allocates new slots, stores parameter values and any remember blocks, and registers the group. On subsequent recompositions, the Composer switches to update mode. It walks through the existing slot table entries, comparing the stored key with the expected key at each position. If the keys match, the composable is the same instance as before and may be updated or skipped. If the keys do not match, the runtime detects a structural change and must insert or remove groups accordingly.
Entering the Composition
A composable enters the Composition when it is called for the first time in a given position. This happens during either the initial composition of the entire tree or when a parent composable introduces a new child, for example through a conditional branch evaluating to true for the first time:
@Composable
fun ProfileScreen(showBadge: Boolean) {
Column {
ProfileHeader()
if (showBadge) {
// Badge enters the Composition when showBadge becomes true
AchievementBadge()
}
}
}
When AchievementBadge enters the Composition, the Composer inserts a new group into the slot table. Any remember blocks inside AchievementBadge execute and store their initial values. Any LaunchedEffect or DisposableEffect blocks register their effects. The Applier then creates the corresponding layout node and inserts it into the node tree at the correct position.
The Applier is the bridge between the Composition and the UI tree. On Android, the standard Applier is UiApplier, which operates on LayoutNode instances. When a composable enters the Composition, the Applier calls insertTopDown() or insertBottomUp() to place the new node:
internal class UiApplier(root: LayoutNode) : AbstractApplier<LayoutNode>(root) {
override fun insertTopDown(index: Int, instance: LayoutNode) {
current.insertAt(index, instance)
}
override fun insertBottomUp(index: Int, instance: LayoutNode) {
// Intentionally empty: UiApplier is a top-down applier
}
override fun remove(index: Int, count: Int) {
current.removeAt(index, count)
}
}
Recomposition and Snapshot State Observation
After a composable has entered the Composition, it may recompose zero or more times. The trigger is always a change to a State<T> object that the composable reads. The mechanism that connects state changes to composable re-execution is the snapshot observation system.
The Compose runtime uses Kotlin's Snapshot system to track reads and writes to state objects. Each mutableStateOf() call creates a SnapshotMutableStateImpl that stores its value inside a StateRecord. When a composable reads a state's .value, the snapshot system notifies the current observer:
internal open class SnapshotMutableStateImpl<T>(
value: T,
override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
override var value: T
get() = next.readable(this).value
set(value) = next.withCurrent {
if (!policy.equivalent(it.value, value)) {
next.overwritable(this, it) { this.value = value }
}
}
}
The readable() call internally triggers Snapshot.readObserver, which in the context of composition is the OwnerSnapshotObserver. This observer maps each state read to the current RecomposeScope, which corresponds to the restart group of the composable function that performed the read.
When a state object is later modified, the write triggers Snapshot.writeObserver, and the affected RecomposeScope instances are marked as invalid. The Recomposer collects these invalid scopes and schedules a recomposition pass on the next frame:
// Inside the Recomposer's frame loop
private suspend fun recompositionRunner(block: suspend CoroutineScope.() -> Unit) {
withContext(broadcastFrameClock) {
block()
}
}
The Recomposer waits for the MonotonicFrameClock to signal a frame, then re-executes only the composable functions associated with invalid scopes. This is why state reads are scoped to specific composables: a state change in one composable does not trigger recomposition of unrelated composables elsewhere in the tree.
Skipping and Stability
During recomposition, the Composer checks whether a composable can be skipped entirely. A composable can be skipped if all of its parameters are stable and equal to their previous values. The Compose compiler plugin generates comparison code at each restart group:
// Compiler-generated skip logic (simplified)
if (changed == 0 && composer.skipping) {
composer.skipToGroupEnd()
} else {
// Execute the composable body
Text(text)
}
A type is considered stable if the Compose compiler can prove that its equals() method is consistent with its identity over time. Primitive types, String, and types annotated with @Stable or @Immutable are stable. Data classes with all stable fields are also stable. Unstable types always trigger recomposition of composables that receive them, because the compiler cannot guarantee that their equals() result is meaningful.
This is why composable functions should receive the narrowest possible parameters. Instead of passing an entire ViewModel or a mutable data holder, pass the specific primitive or stable values that the composable actually needs. This allows the skip check to prevent unnecessary re-execution:
// Prefer this: narrow, stable parameters
@Composable
fun UserName(name: String) {
Text(text = name)
}
// Over this: unstable parameter forces recomposition every time
@Composable
fun UserName(viewModel: UserViewModel) {
Text(text = viewModel.name)
}
Leaving the Composition
A composable leaves the Composition when its parent no longer calls it. This happens when a conditional branch changes, a list shrinks, or the parent itself leaves the Composition. The runtime detects this during recomposition when the Composer's slot table walk encounters groups that no longer have corresponding calls.
When a composable leaves, the runtime performs cleanup in a specific order. First, all DisposableEffect blocks within the composable have their onDispose callbacks invoked:
@Composable
fun SensorListener(sensorManager: SensorManager) {
DisposableEffect(Unit) {
val listener = object : SensorEventListener { /* ... */ }
sensorManager.registerListener(listener, /* ... */)
onDispose {
// Called when this composable leaves the Composition
sensorManager.unregisterListener(listener)
}
}
}
Second, any LaunchedEffect coroutines are cancelled. The runtime cancels the CoroutineScope associated with the effect, which follows standard structured concurrency cancellation. Third, the remember blocks have their stored values discarded from the slot table. If a remembered value implements RememberObserver, its onForgotten() callback is invoked. Note that onAbandoned() is a separate callback that is only called when a RememberObserver was created but the composition was abandoned before it could be successfully committed, so onForgotten() and onAbandoned() are mutually exclusive. Finally, the Applier removes the corresponding layout node from the node tree.
This cleanup chain ensures that resources acquired during a composable's lifetime are released when it leaves. The order matters: effects are disposed before remembered values are forgotten, so effect disposal code can safely reference remembered values.
Positional Keys and the key() Composable
By default, the Compose runtime identifies composable instances by their call site position within the parent. This works well for static layouts but breaks down when composables are created in loops or when list items are reordered. The key() composable overrides positional identity with a value based identity:
@Composable
fun MessageList(messages: List<Message>) {
Column {
for (message in messages) {
key(message.id) {
MessageRow(message)
}
}
}
}
Without key(), inserting a message at the beginning of the list shifts the positional identity of every subsequent MessageRow. The runtime treats each position as having received new data, triggering full recomposition of every item and resetting any internal state. With key(message.id), the runtime matches each MessageRow to its message by ID. Reordering the list only moves groups within the slot table without resetting their state.
Internally, the key() composable generates a startMovableGroup() call instead of a regular startGroup(). Movable groups are tracked by their key value rather than by position. During recomposition, the Composer maintains a lookup table of movable groups and matches them by key, allowing it to detect insertions, deletions, and moves without treating them as full replacements.
Summary
The lifecycle of a composable is governed by the Composer's slot table management. A composable enters the Composition when a new group is inserted into the slot table, recomposes when its associated RecomposeScope is invalidated by a snapshot state change, and leaves when the Composer detects that its group no longer has a corresponding call during recomposition. The snapshot observation system provides precise tracking of which state objects are read by which scopes, enabling targeted recomposition. Skipping logic based on parameter stability prevents unnecessary re-execution. Cleanup on exit follows a defined order: effect disposal, coroutine cancellation, remembered value removal, and node tree deletion.
Describe the three main events that define the lifecycle of a composable in Jetpack Compose. What typically triggers a recomposition, and how does Compose identify which composables need to be re-executed?
Before revealing the answer, imagine you're in a real interview and think through your response. This kind of deep, prefrontal thinking helps transfer difficult concepts into long-term memory.
Follow-up Q: How does the Compose compiler plugin transform composable functions, and what role does the $changed parameter play in skip optimization?
Before revealing the answer, imagine you're in a real interview and think through your response. This kind of deep, prefrontal thinking helps transfer difficult concepts into long-term memory.

