Stop Recomposing on Every Scroll Pixel: derivedStateOf in Jetpack Compose
Reading LazyListState directly recomposes your UI on every scroll pixel. derivedStateOf limits recomposition to when the computed result actually changes — here's how it works and when it backfires.
21 Mar 2026, 10:26 UTC

Your list scrolls smoothly, but Layout Inspector shows a composable recomposing hundreds of times while the user drags a finger. The culprit is usually a composable that reads LazyListState directly — say, to decide whether a "jump to top" button should appear. The fix is not caching or throttling; it's derivedStateOf, which lets you recompose only when a computed result changes, not when its fast-moving inputs do.
Why scrolling triggers so much recomposition
Compose tracks which snapshot state objects each composable reads. When any of those states change, the composable is invalidated and recomposed. LazyListState.firstVisibleItemScrollOffset changes on essentially every pixel of a scroll. If your composable reads it — even indirectly, through a calculation — it gets invalidated at the same rate, even if the value you actually care about ("is the user past the first item?") stays the same for thousands of pixels.
The mismatch is the problem: the input changes constantly, but the boolean you render from it flips rarely. Recomposition is cheap in small doses, but at scroll frequency it adds up, and it makes performance profiles noisy.
What derivedStateOf actually does
derivedStateOf wraps a calculation and produces a new State. Compose still tracks the states read inside the calculation and re-runs it when they change — but observers of the derived state are only notified when the output differs from the previous output. So the calculation may run on every pixel, while your composable recomposes only when the boolean flips.
Two details matter:
- You must
rememberthe derived state. Creating it inline on every recomposition throws away the previous output, so there's nothing to compare against and the optimization defeats itself. - It only limits composition. It does not reduce layout or drawing work, and it doesn't make the underlying calculation free.
Worked example: a jump-to-top button
Here's the naive version, which recomposes the button's parent on every scroll offset change:
@Composable
fun FeedList(listState: LazyListState) {
// Reads scroll state directly: invalidates on every scroll frame
val showJumpToTop = listState.firstVisibleItemIndex > 0
Box {
LazyColumn(state = listState) { /* items */ }
if (showJumpToTop) {
JumpToTopButton(onClick = { /* scroll to top */ })
}
}
}And the version that recomposes only when the flag flips:
@Composable
fun FeedList(listState: LazyListState) {
val showJumpToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Box {
LazyColumn(state = listState) { /* items */ }
if (showJumpToTop) {
JumpToTopButton(onClick = { /* scroll to top */ })
}
}
}Both snippets run in a composable with access to a LazyListState (create one with rememberLazyListState() and pass it to LazyColumn). No extra permissions or setup are required. The risk is low — this is a pure code change — but verify behavior rather than assuming it: open Layout Inspector in Android Studio, enable recomposition counts, and scroll. With the direct read, the count climbs continuously; with derivedStateOf, it should tick only when the first item leaves or re-enters view. Behavior around invalidation can shift across Compose releases, so confirm against the Compose BOM version your project actually uses.
When derivedStateOf hurts instead of helps
The API pays off only when the input changes far more often than the output. If you wrap a cheap calculation whose inputs change at the same rate as its result — for example, formatting a string from a state that updates once — you've added bookkeeping overhead for nothing. Worse, sprinkling derivedStateOf everywhere can mask an architectural problem: if unrelated UI recomposes constantly, the real fix is usually better state hoisting or narrower state reads, not another layer of derived state.
Two alternatives are worth knowing:
- Read state in a lambda modifier.
Modifier.offset { IntOffset(x, y) }reads state during layout, skipping composition entirely. For purely visual effects like parallax, this is often better thanderivedStateOf. - snapshotFlow. If you need the value outside composition — logging, analytics, syncing to a non-Compose system —
snapshotFlow { ... }converts state reads into a Flow that emits only on changes, similar todistinctUntilChanged.
Closing: measure, then apply
The rule of thumb: reach for derivedStateOf when a noisy input feeds a stable output consumed in composition. Before and after the change, check recomposition counts in Layout Inspector while reproducing the real interaction — scrolling, in this case. If the count doesn't drop, the bottleneck is elsewhere, and no amount of derived state will fix it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.