A composable's lifecycle is three events, and none of them is onDestroy
Composables have a lifecycle, but it is nothing like a View's. Three events — enter, recompose, leave — and the identity rules that decide which one you get.

Day 8 of 100. Yesterday: recomposition re-runs the scopes that read what changed. Today: what "the composition" actually is, and the three things that can happen to a composable inside it.
The symptom
A row that can be expanded. Expand the third one, delete the first, and the second row is now expanded.
@Composable
fun Feed(users: List<User>) {
Column {
users.forEach { user ->
var expanded by remember { mutableStateOf(false) }
UserRow(user, expanded, onToggle = { expanded = !expanded })
}
}
}
Nobody toggled the second row. The state moved.
The instinct is that remember is buggy, or that the list needs a stable sort, or
that the state should live in a ViewModel. None of those is the problem, and the
ViewModel version has exactly the same bug for exactly the same reason.

Why the obvious fix fails
The obvious fix is to key the state by user:
val expandedIds = remember { mutableStateMapOf<String, Boolean>() }
users.forEach { user ->
UserRow(user, expandedIds[user.id] == true) { expandedIds[user.id] = … }
}
This works. It also hides the lesson, and the moment you have per-row animation state, scroll state, or a text field inside the row, you're hand-rolling an identity map for every one of them.
The reason it works is worth extracting, because it generalises.
The actual mechanism
A composable has exactly three lifecycle events:
- Enters the composition — the first time this call site executes
- Recomposes — zero or more times, while it stays in the composition
- Leaves the composition — when its call site no longer executes
There is no onDestroy, no onPause, and no notion of "this composable instance".
The composition is a tree of call sites, and Compose stores remembered values against
those sites in the slot table.
The critical part: a call site's identity is positional by default. Compose knows
"the third remember inside the forEach" — not "the remember belonging to Ada".
So when the first user is deleted:
- The
forEachproduces one fewer iteration - What was position 3 is now position 2
- The slot at position 2 still holds
expanded = truefrom before - The user at position 2 is now a different person
Nothing moved the state. The state stayed exactly where it was; the data moved past it. That's why a ViewModel doesn't help — the problem is the identity of the slot, not where the value is stored.
key fixes it by replacing positional identity with one you choose:
users.forEach { user ->
key(user.id) {
var expanded by remember { mutableStateOf(false) }
UserRow(user, expanded, onToggle = { expanded = !expanded })
}
}
Now the slot belongs to user.id. Delete a user and their slot leaves the composition
with them. Reorder the list and each slot travels with its user.
This is the same reason LazyColumn's items() takes a key parameter, and why
omitting it produces scroll jumps and animations that attach to the wrong row.
Leaving the composition, and why it matters
Leaving is the event people forget exists, and it's the one that leaks resources.
When a composable leaves, its remembered values are discarded. If one of those values held something that needs releasing — a listener, a subscription, a coroutine — nothing releases it unless you asked for that explicitly:
@Composable
fun LocationDisplay(client: LocationClient) {
var location by remember { mutableStateOf<Location?>(null) }
DisposableEffect(client) {
val listener = client.addListener { location = it }
onDispose { client.removeListener(listener) } // runs on leave
}
Text(location?.toString() ?: "Locating…")
}
onDispose runs when the composable leaves the composition or when the effect's
key changes. That second case is easy to miss: if client changes, the old listener is
disposed and a new one registered, which is almost always what you want and
occasionally a surprise.
The rule worth carrying: anything you acquire in composition must be released on
leave, and DisposableEffect is the only mechanism that gives you that hook.
What "leaves" does NOT mean
Three things that are not composition-leave events, and conflating them causes bugs:
Screen rotation. The Activity is recreated, the whole composition is torn down and
rebuilt. Every remember is lost. Surviving that needs rememberSaveable, which is
Day 14.
Navigating away. Depends entirely on your navigation setup — a destination popped off the back stack leaves the composition, one merely covered by another may not.
The app backgrounding. The composition typically stays alive. remember values
survive. This is why an in-composition timer keeps running unless something stops it,
and why observing lifecycle explicitly (LifecycleEventEffect, or collecting with
flowWithLifecycle) matters for anything expensive.
Composition lifetime and Android lifecycle are different clocks. Most "my state disappeared" and "my listener kept firing" bugs come from assuming they tick together.
Why remember keys exist, and when to use them
remember takes keys for the same reason effects do — they declare when the cached
value becomes invalid:
// Recomputed only when `items` changes, not on every recomposition
val sorted = remember(items) { items.sortedBy { it.name } }
Without the key, the sort runs once and never updates when items changes — a stale
list that looks like a data-layer bug. With too broad a key (an unstable object), it
recomputes every composition and you've paid for remember while getting nothing.
The mental model that makes this consistent across the whole API: remember(keys),
LaunchedEffect(keys) and DisposableEffect(keys) all mean the same thing — "this
is valid until one of these changes". Learn it once, apply it three places.
The most expensive version of this bug
The list case is annoying. The same mechanism in a LazyColumn is worse, because
lazy layouts reuse slots aggressively by design:
LazyColumn {
items(users) { user -> // no key
var expanded by remember { mutableStateOf(false) }
UserRow(user, expanded)
}
}
Scroll down, scroll back, and expansion state has attached to whichever rows happen to occupy the reused slots. It looks intermittent, it's hard to reproduce deliberately, and it gets blamed on scroll position or the adapter.
items(users, key = { it.id }) { user -> … }
One parameter. It also makes item animations correct, because Compose can finally tell "moved" apart from "replaced" — the same information, used twice.
How to prove it
Log all three events for a single composable:
@Composable
fun Traced(label: String) {
DisposableEffect(Unit) {
Log.d("Lifecycle", "$label ENTER")
onDispose { Log.d("Lifecycle", "$label LEAVE") }
}
SideEffect { Log.d("Lifecycle", "$label RECOMPOSE") }
}
Drop it inside a list row, then add, remove and reorder items. Without key you'll see
recompositions where you expected enter/leave pairs — the slot was reused for different
data. With key, you get the enter/leave pairs the data actually implies.
That log difference is the concept. Ten minutes with it is worth more than any explanation, including this one.
What this generalizes to
The general shape: identity is not free, and defaults are positional. React's
reconciler makes the same trade with its key prop, and for the same reason — a
diffing algorithm needs to know whether two things across two renders are "the same
thing", and position is the only answer available without help.
Any time state seems to attach to the wrong item in a list, in any declarative UI framework, the first question is whether you told the framework what identity means.
Tomorrow, Day 9: side-effects — why a composable that starts a network call is a bug, and where that call actually belongs.
Day 8 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Lifecycle of composables.