5 min read

Compose Deferred Reads: Passing Lambdas Instead of Values

Where you read a State decides which of Compose's three phases invalidates. Reading a scroll offset in a composable body recomposes; reading it inside a layout lambda only re-lays-out. The technique, and the far more important question of when not to use it.

AndroidJetpack ComposePerformanceRecompositionKotlin
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Compose invalidates from the phase where a State is read: composition, layout, or draw. Reading a high-frequency value in a composable body triggers all three; reading it inside a lambda that layout or draw invokes triggers only that phase. Use the lambda-parameter version (Modifier.offset { … }, graphicsLayer { … }, count: () -> Int) for values changing at frame rate — scroll, animation, drag. Use plain values everywhere else; deferring a label that changes twice a session is indirection for nothing.

The three phases

Every frame Compose can do up to three things:

  1. Composition — run composable functions, produce/update the tree.
  2. Layout — measure and place.
  3. Draw — issue drawing commands.

They're ordered, so invalidating an earlier one invalidates everything after it. Composition is by far the most expensive.

The mechanism that matters: Compose records which phase was running when a snapshot State was read, and invalidates from there. So the phase you read in is the phase you pay for.

The canonical example

// Read during composition → recomposition, then layout, then draw
Box(Modifier.offset(y = scrollState.value.dp))

// Read during layout → layout and draw only
Box(Modifier.offset { IntOffset(x = 0, y = scrollState.value) })

Same visual result. On a 120Hz display during a fling, the first version re-executes the composable 120 times a second along with everything it contains; the second skips composition entirely.

The lambda overloads exist across the API for exactly this reason:

Modifier.offset { IntOffset(0, offsetPx) }          // layout phase
Modifier.graphicsLayer { alpha = fade; scaleX = s } // draw phase only
Modifier.drawBehind { drawRect(color) }             // draw phase only

graphicsLayer with a lambda is the strongest of the three: alpha, scale, rotation and translation applied there skip both composition and layout. A fade-out that reads its alpha in the composable body recomposes a subtree 60 times a second to produce something the GPU could have done for free.

The same idea one level up: lambda parameters

The technique generalises beyond modifiers. If a child needs a value, pass a way to get the value rather than the value itself:

// The read happens in Screen → Screen recomposes on every change
@Composable fun Screen(vm: ScreenViewModel) {
    Header(scrollOffset = vm.scrollOffset)   // Int
    ExpensiveList(...)                        // recomposes with the parent
}

// The read happens in Header → only Header recomposes
@Composable fun Screen(vm: ScreenViewModel) {
    Header(scrollOffset = { vm.scrollOffset }) // () -> Int
    ExpensiveList(...)                          // untouched
}

Nothing got cheaper. The invalidation scope got smaller, which is the whole game — Compose skips subtrees whose inputs didn't change, and a lambda parameter is a stable input even when the value behind it changes constantly.

This is also why Modifier.clickable { vm.onClick() } doesn't cause trouble while Modifier.clickable(enabled = vm.isEnabled) does: one defers, the other reads now.

A real collapsing-header, done both ways

// Before: every scroll pixel recomposes the header and its children
@Composable
fun CollapsingHeader(scroll: ScrollState) {
    val progress = (scroll.value / 300f).coerceIn(0f, 1f)
    Column(Modifier.alpha(1f - progress)) {
        Title(); Subtitle(); Avatar()
    }
}

// After: no recomposition at all while scrolling
@Composable
fun CollapsingHeader(scroll: ScrollState) {
    Column(
        Modifier.graphicsLayer {
            alpha = 1f - (scroll.value / 300f).coerceIn(0f, 1f)
        }
    ) {
        Title(); Subtitle(); Avatar()
    }
}

Three children that no longer re-execute per frame. If any of them does non-trivial work — formats a date, builds an AnnotatedString, reads another state — that's the difference between smooth and janky.

When not to do this

This is the part that gets skipped, and it's where the technique goes wrong in real codebases.

() -> String for a username that changes twice per session is worse code with no benefit. You've added indirection, made the parameter harder to read, made previews more awkward, and saved nothing — because a recomposition that happens twice a session costs nothing.

The heuristic:

  • Defer when the value changes at frame rate: scroll, drag, animation, a continuously updating sensor or progress value.
  • Defer when the subtree between the read and the use is expensive.
  • Don't defer for user-driven state — text input, toggles, selection, navigation. It changes at human speed.
  • Don't defer as a default style. A codebase where every parameter is a lambda is harder to read and doesn't measurably outperform one that defers in the four places it matters.

Confirm it, don't assume it

// See recompositions live: Layout Inspector → recomposition counts
// Or in code:
val count = remember { mutableIntStateOf(0) }
SideEffect { count.intValue++ }

Layout Inspector's recomposition counts are the fastest check: scroll the screen, watch which nodes' counters climb. A collapsing header whose children count in the thousands after one fling is the exact signature this post is about. The Compose compiler metrics report tells you the complementary half — which composables are skippable at all.

The rule

Read state as late and as low in the tree as you can. High-frequency state read high in the tree is the most reliable jank source in Compose, and it never looks like a performance bug in review — it looks like perfectly ordinary code that passes a number to a function.

Keep reading