Recomposition doesn't re-run your screen. It re-runs the scopes that read what changed.

Compose does not re-run your whole screen when state changes. It re-runs the smallest restartable scopes that read the changed value. Once you can see those scopes, recomposition stops being mysterious.

6 min read
androidcomposekotlinperformance

Day 7 — Recomposition re-runs scopes, not screens

Day 7 of 100, and the start of the UI-architecture pillar. Everything in the next eleven posts rests on this one idea, so it's worth getting exactly right.

The symptom

A screen with a text field and a list. Typing in the field makes the list stutter.

@Composable
fun SearchScreen(items: List<Item>) {
    var query by remember { mutableStateOf("") }

    Column {
        TextField(value = query, onValueChange = { query = it })
        LazyColumn {
            items(items) { ExpensiveRow(it) }
        }
    }
}

The list doesn't depend on query. It never reads it. Yet every keystroke drops frames, and Layout Inspector shows ExpensiveRow recomposing on each one.

The usual conclusion is "Compose re-runs the whole screen on any state change, so I need to split this into smaller composables". That conclusion is half right, and the half that's wrong will send you refactoring in the wrong direction for weeks.

Recomposition invalidates the nearest restartable scope that read the changed value, not the whole screen

Why the obvious fix fails

The obvious fix is to extract the list:

@Composable
fun SearchScreen(items: List<Item>) {
    var query by remember { mutableStateOf("") }
    Column {
        TextField(value = query, onValueChange = { query = it })
        ItemList(items)          // extracted — surely this stops recomposing?
    }
}

Run it. ItemList still recomposes on every keystroke.

Extraction alone changed nothing, because the problem was never "the screen is too big". If you stop here you conclude Compose's skipping doesn't work, and start reaching for derivedStateOf and key and manual memoisation — none of which address what's actually happening.

The actual mechanism

Compose tracks state reads at the granularity of restartable scopes. A restartable scope is, roughly, the body of a composable function that the compiler marked as restartable — it can be re-executed independently of its caller.

When a MutableState is written, Compose looks up every scope that read that state during the last composition, marks those invalid, and re-runs exactly those on the next frame. Not the screen. Not the parents. The reading scopes.

So the real question for any recomposition problem is: which scope read the value?

In the code above, query is read inside SearchScreen's own body — the TextField(value = query, …) argument is evaluated there. So the invalidated scope is SearchScreen. Re-running SearchScreen means re-invoking everything it calls, including ItemList(items).

ItemList then gets a chance to skip: if its parameters are unchanged and it's skippable, it returns immediately. Whether it does comes down to whether items is stable. List<Item> is an interface — the compiler can't know an implementation won't mutate — so it's treated as unstable, no "unchanged" bit is set, and the body runs again.

Two separate mechanisms, and confusing them is what makes recomposition feel random:

  • Invalidation decides which scopes re-run. Driven by where you read state.
  • Skipping decides whether a re-invoked child actually executes. Driven by parameter stability.

Extraction only helps if the extracted composable can skip. Extracting an unstable parameter into a new function achieves nothing at all.

The fix

Two independent moves, and you usually want both.

1. Move the read down, so the invalidated scope is smaller. Instead of reading query in SearchScreen, pass a lambda and let the field own its own state:

@Composable
fun SearchScreen(items: List<Item>) {
    Column {
        SearchField()            // reads `query` inside ITS scope
        ItemList(items)
    }
}

@Composable
private fun SearchField() {
    var query by remember { mutableStateOf("") }
    TextField(value = query, onValueChange = { query = it })
}

Now the write invalidates SearchField only. SearchScreen never read query, so it is never invalidated, so ItemList is never even re-invoked — stability becomes irrelevant because the question never arises.

2. Make the parameter stable, so a re-invoked child can skip when it is invoked:

// kotlinx.collections.immutable
@Composable
fun ItemList(items: ImmutableList<Item>) { … }

Fix 1 is structural and almost always the better lever. Fix 2 is insurance for the times a parent genuinely must re-run.

When the read genuinely can't move down

Sometimes a parent must read state because it derives something from it. The classic is a scroll-driven flag:

@Composable
fun Screen(listState: LazyListState) {
    // Reads scroll offset EVERY pixel — recomposes on every frame of a fling.
    val showButton = listState.firstVisibleItemIndex > 0
    Column {
        if (showButton) ScrollToTopButton()
        LazyColumn(state = listState) { … }
    }
}

firstVisibleItemIndex changes constantly while scrolling, so this scope invalidates constantly — even though showButton only flips between two values.

derivedStateOf exists for exactly this: read the noisy source inside it, and the scope only invalidates when the derived result changes.

val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

Now the scope invalidates twice per scroll — once crossing zero, once coming back — instead of on every frame.

The distinction that matters: use derivedStateOf when a frequently-changing input maps to a rarely-changing output. It is not a general memoisation tool. Wrapping a value that changes as often as its source adds an allocation and a layer of indirection for nothing, which is the most common way it gets misused.

The lambda trap

One more case that catches people once and then never again. Lambdas capture, and a captured unstable value makes the lambda itself unstable:

// `onClick` is a NEW lambda every composition, capturing `item`
ItemRow(item = item, onClick = { viewModel.select(item) })

If item is unstable, the lambda is too, and ItemRow can never skip — even with every other parameter stable. The compiler report shows this as an unstable lambda parameter, which reads confusingly until you know it's the capture, not the lambda.

The fix is to stabilise what's captured, or to hoist the callback so it captures an id rather than the object:

ItemRow(item = item, onClick = remember(item.id) { { viewModel.select(item.id) } })

How to prove it

Don't guess at any of this. The compiler will tell you what it decided:

./gradlew :app:assembleRelease \
  -Pandroidx.compose.compiler.plugins.kotlin.reportsDestination=build/compose-reports

*-composables.txt lists every composable with restartable and skippable flags, and marks each parameter stable or unstable:

restartable skippable fun ItemList(
  stable modifier: Modifier? = @static Modifier.Companion
  unstable items: List<Item>
)

That unstable items line is the whole diagnosis, printed by the build.

For the runtime side, Layout Inspector's recomposition counts show which composables re-ran and how often. The pairing worth internalising: the compiler report tells you what can skip; Layout Inspector tells you what actually did.

What this generalizes to

The reusable idea is that the read site is the subscription site. Reading a value is what subscribes a scope to it, so pushing reads downward shrinks what invalidates — the same instinct as keeping variable scope tight, with a performance consequence attached.

This is also why the deferred-read APIs exist. Modifier.offset { } taking a lambda rather than a value isn't ceremony: it moves the read out of composition and into layout, so a changing offset never invalidates composition at all. Same principle, applied one phase later — which is Day 10.

Tomorrow, Day 8: the lifecycle of a composable, and why "it's just a function" stops being a useful description the moment state enters.


Day 7 of a 100-day series on Jetpack Compose, working through the official documentation in order. Sources: Thinking in Compose and Compose performance.