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.

6 min read
androidcomposekotlinstate

Day 8 — Enters, recomposes, leaves

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.

A composable enters the composition, recomposes while it remains, and leaves — identity decides which

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:

  1. Enters the composition — the first time this call site executes
  2. Recomposes — zero or more times, while it stays in the composition
  3. 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 forEach produces one fewer iteration
  • What was position 3 is now position 2
  • The slot at position 2 still holds expanded = true from 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.