What actually triggers a Jetpack Compose recomposition, and how do you avoid the unnecessary ones?
Recomposition isn't re-rendering the whole tree — it's Compose deciding, function by function, whether your inputs are still the same, and stability is what it checks.
LEARN BY DOINGPut your intuition to work3 steps
What actually triggers a Jetpack Compose recomposition, and how do you avoid the unnecessary ones?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What "recomposition" really means
Jetpack Compose does not re-render your whole UI tree on every state change the way a naive mental model suggests. The unit of work in Compose is the composable function, and recomposition means Compose re-invoking the specific composable functions whose inputs it can no longer prove are unchanged, while skipping every function whose inputs compare as equal to the previous call. This is why people describe Compose as doing "smart" or "granular" recomposition: a state change three levels deep in a tree can, in principle, re-run only the leaf composable that reads it, leaving its parents, siblings, and children untouched.
The Compose compiler plugin is what makes this possible. It rewrites every @Composable function to accept extra, hidden parameters — a Composer and a "changed" bitmask — and inserts comparison logic around each call site. When a composable is about to be invoked again, the runtime checks whether every parameter's value is equal to what it was on the previous call. If they are all equal and the composable is marked "skippable" (which depends on stability, covered below), Compose skips calling the function entirely and reuses the UI it already emitted.
#The three real triggers
Set aside the vague idea of "when state changes" and name the actual triggers precisely:
- A
State<T>you read during composition changes. This is the Snapshot system:mutableStateOf,derivedStateOf,collectAsState, and friends all produce aState<T>, and reading its.valueinside a composable's body registers that composable as a subscriber. When the snapshot commits a new value, every subscriber is scheduled for recomposition. - A parent recomposes and calls you with different arguments — and your function isn't skippable for those arguments, so Compose can't prove nothing changed.
- A
CompositionLocalyou read changes value.
That is the whole list. Notably, "the parent recomposed" is not, by itself, a reason for a child to recompose — if the parent passes exactly the values it passed last time, and the child is skippable, Compose skips it entirely. This is the detail most engineers get wrong, and exactly where "unnecessary recomposition" bugs live.
#Stability is what makes a parameter trustworthy
For Compose to skip a call, it has to trust that "equal" really means "equal" for every parameter type. The compiler classifies each type as stable or unstable:
- Stable: all primitive types,
String, function types (lambdas, subject to the memoization rules below), and any class where every property is a stableval— inferred automatically for classes in your own module, or declared explicitly with@Stable/@Immutable. - Unstable: plain Kotlin
List,Set, andMap(the compiler cannot prove that aListinterface reference isn't secretly aMutableListbeing mutated elsewhere, so it never trusts collection interfaces), anyinterfacetype (the compiler has no idea what the concrete implementation's stability actually is, unless you assert it with@Stable), and any class that exposes avarproperty.
An unstable parameter makes the composable unskippable for that parameter: Compose gives up trying to compare it and simply calls the function again. That single fact is the most common root cause of "why does this recompose every single time" bugs in real codebases.
#A concrete before / after
// BEFORE — recomposes far more than it needs to.@Composablefun ScreenContainer(viewModel: ItemsViewModel) {val uiState by viewModel.uiState.collectAsStateWithLifecycle()// A brand-new List instance is allocated on EVERY recomposition of// ScreenContainer, even when the visible items haven't actually changed.ItemList(items = uiState.items.filter { it.isVisible },onItemClick = { item -> viewModel.select(item) },)}@Composablefun ItemList(items: List<Item>, onItemClick: (Item) -> Unit) {// List<Item> is an unstable type to the compiler, so this whole// function is unskippable — it recomposes every time its parent does,// regardless of whether `items` is structurally the same list.LazyColumn {items(items, key = { it.id }) { item ->ItemRow(item = item, onClick = { onItemClick(item) })}}}data class Item(val id: String, val label: String, val isVisible: Boolean)
// AFTER — stable inputs, and the filter only re-runs when the source list changes.@Immutabledata class Item(val id: String, val label: String, val isVisible: Boolean)@Composablefun ScreenContainer(viewModel: ItemsViewModel) {val uiState by viewModel.uiState.collectAsStateWithLifecycle()// derivedStateOf recomputes on every uiState.items change, but the// downstream reader only recomposes when the FILTERED result differs.val visibleItems by remember(uiState.items) {derivedStateOf { uiState.items.filter { it.isVisible }.toImmutableList() }}ItemList(items = visibleItems, onItemClick = { item -> viewModel.select(item) })}@Composablefun ItemList(items: ImmutableList<Item>, onItemClick: (Item) -> Unit) {// ImmutableList is stable, Item is @Immutable, and the lambda has a// stable capture — this composable is now genuinely skippable.LazyColumn {items(items, key = { it.id }) { item ->ItemRow(item = item, onClick = { onItemClick(item) })}}}
#Table: what recomposes, and why
| Parameter type | Compiler stability | Effect on skipping |
|---|---|---|
Int, Boolean, String | Stable | Skippable if the value is == to last time |
data class with only val fields of stable types | Stable (inferred) | Skippable |
List<T>, Map<K, V> (kotlin.collections) | Unstable | Never skippable — recomposes on every parent recomposition |
ImmutableList<T> (kotlinx.collections.immutable) | Stable | Skippable |
Plain interface type | Unstable (unknown) | Never skippable unless annotated @Stable |
| Lambda with only stable captures | Stable, memoized automatically | Skippable |
| Lambda capturing an unstable value | New instance on every call | Never skippable |
#Strong skipping mode changes the calculus
Since the Compose compiler shipped alongside Kotlin 2.0, strong skipping mode has been on by default. It makes even unstable-typed parameters skippable by falling back to a runtime instance/equals() check instead of refusing to compare them outright, and it automatically wraps every lambda parameter in remember so you don't have to hand-write that anymore. It closes most of the historical "just wrap it in remember yourself" advice for lambdas — but it does not fix a List being reallocated with equal-but-different-instance content on every call: instance equality still says "different," so the cost of a genuinely new list on every recomposition is still real.
#The decision Compose makes on every call
🧠 Check your understanding
0/3 · 0/3 answered1. A composable takes a List<String> parameter. The list's contents never change, but the parent allocates a new List instance with the same elements on every recomposition. What happens?
2. What does 'strong skipping mode' add on top of the classic stability rules?
3. Which of these is NOT one of the three things that trigger a composable to recompose?