Recomposition in Jetpack Compose: what actually triggers it and how I stopped guessing
A practical guide to Jetpack Compose recomposition in 2026 — type stability, unstable lambdas, LazyColumn keys, and reading the compiler's own metrics report.
A Compose screen that stutters almost never has an obvious cause. There’s no stack trace, no crash, no lint warning — just a list that feels a beat slower than it should, or a text field that lags half a frame behind every keystroke. The usual suspect is recomposition running on more of the UI tree than it needs to, and the frustrating part is that Compose won’t tell you when this happens. It just quietly does more work than necessary, every frame, until you go looking for it. Here’s what actually decides whether a composable gets skipped or re-run, and the two tools that replace guessing with reading.
What recomposition actually is
Compose tracks state reads, not variables. When a composable function reads a State<T> — directly or through remember { mutableStateOf(...) } — the runtime records that read against the current composition scope. When the state’s value changes, only the scopes that actually read it are scheduled to re-run. Everything else in the tree is left alone, in theory. The word doing the work in that sentence is “scopes that actually read it,” and figuring out which scopes those are is where things go wrong.
The compiler’s real question: is this type stable?
Every composable function’s parameters get classified by the Compose compiler as stable or unstable. A stable type is one the compiler can prove won’t change without notifying the composition — so if the value is equals()-equal to what it was last time, Compose skips re-running the function entirely. An unstable type can’t be trusted that way, so the composable using it re-runs on every recomposition of its parent, whether anything it actually reads has changed or not.
// Stable: every property is val, and String/Int are stable by definition.
data class PantryItem(val id: Long, val name: String, val quantity: Int)
// Unstable: a var property means the compiler can't prove this instance
// won't mutate out from under a composable that's holding a reference to it.
data class PantryItemDraft(var name: String, var quantity: Int)
The var version isn’t a style nitpick — it’s the difference between a composable that Compose can skip and one it can’t. The same problem shows up with plain List<T> and Map<K, V> parameters: the compiler treats the List interface as unstable because nothing stops the caller from handing it a MutableList and mutating it later. A data class full of stable val fields wrapped in a plain List is still an unstable parameter, and that’s the single most common place stability quietly breaks in a real codebase.
Where it sneaks in unnoticed
Two patterns account for most of the unnecessary recomposition I’ve traced in my own screens:
Un-hoisted lambdas. A lambda defined inline inside a composable body is a new object on every recomposition of the parent, even if its captured values haven’t changed. Passed down to a child composable, that new instance fails the equality check and forces the child to recompose regardless of whether anything it reads actually changed.
// Recreated every time PantryScreen recomposes — defeats skipping on ItemRow.
PantryList(items = items, onDelete = { id -> viewModel.delete(id) })
// Hoisted once, stable across recompositions of PantryScreen.
val onDelete = remember(viewModel) { { id: Long -> viewModel.delete(id) } }
PantryList(items = items, onDelete = onDelete)
Collections passed straight through from a repository. A Flow<List<PantryItem>> collected with collectAsStateWithLifecycle() gives you a fresh List instance on every emission, even if the contents are identical — which is common right after a Room query re-runs on an unrelated table write. If that list is passed to several child composables, all of them recompose on every emission. Wrapping the read side in derivedStateOf fixes this when the thing you actually care about is a computed value, not the raw list itself:
val isEmpty by remember {
derivedStateOf { items.isEmpty() }
}
derivedStateOf only triggers recomposition when its computed output changes, not when its inputs change — so a list that goes from 40 items to 41 doesn’t re-trigger a composable that only cares whether the list is empty.
Lists: the key that scopes recomposition per row
LazyColumn defaults to using item position as the identity key. Reorder, insert, or delete an item anywhere but the end, and every row after that index gets treated as “changed” — because its position, not its content, is what Compose is tracking. Supplying an explicit, stable key fixes this by scoping recomposition to the rows whose actual content changed:
LazyColumn {
items(items = pantryItems, key = { it.id }) { item ->
PantryItemRow(item)
}
}
This is the exact pattern behind Stocky’s pantry list, which routinely holds a few hundred rows from barcode scans and receipt imports — without a stable key, editing the quantity on one item recomposes the whole visible list instead of the one row that changed.
Reading the compiler’s own report
Guessing which composables are unstable is unnecessary — the Compose compiler can tell you directly. Adding a metrics flag to the Gradle build produces a report listing every composable function, whether it’s skippable, and exactly which parameter made it unstable:
// build.gradle.kts
composeCompiler {
metricsDestination = layout.buildDirectory.dir("compose_metrics")
reportsDestination = layout.buildDirectory.dir("compose_metrics")
}
The generated *-composables.txt report names the unstable parameter on every function it flags, which turns “why is this screen slow” from a profiling session into a grep. I run this before shipping any screen with a list or a frequently-updating value — it’s cheap, it’s exact, and it catches the var-in-a-data-class mistake in about the time it takes to open the file.
The takeaway
Recomposition isn’t inherently a performance problem — Compose is designed to re-run functions cheaply and often. The problem is unscoped recomposition: a whole screen re-running because one unstable parameter, one un-hoisted lambda, or one missing list key told the compiler it couldn’t prove anything was safe to skip. Fix those three things — stable types with val fields, hoisted lambdas, and explicit keys on every LazyColumn item — and most of what looks like a Compose performance problem turns out to already be solved.
// Related reading
More from the journal
The Android In-App Review API: asking for a rating without being annoying
A practical guide to Google's In-App Review API — how it actually works, where to trigger it, and why the usual 'rate us' popup is quietly hurting your Play Store rating.
Android app shortcuts and Quick Settings Tiles: logging water without opening the app
A practical guide to Android's dynamic ShortcutManager API and TileService — how to let a one-tap action skip the app entirely, with the pitfalls that trip most implementations.
Foreground service types on Android in 2026: picking the one your feature actually qualifies for
A practical guide to Android's foreground service type restrictions — dataSync, mediaPlayback, specialUse, shortService — and how to pick the right one without getting killed or rejected.