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.
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:
- Composition — run composable functions, produce/update the tree.
- Layout — measure and place.
- 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.