derivedStateOf exists because you were recomposing on every keystroke
A computed value read in composition invalidates on every change of its source. derivedStateOf narrows it to the result.
TL;DR
Compose tracks which State objects each composable reads. Read one, and that composable is
invalidated whenever it changes. So a composable that reads scrollState.firstVisibleItemIndex to
compute a boolean recomposes on every scroll frame — even though the boolean changes twice.
derivedStateOf inserts a new State whose value is the result. The computation still runs on
every source change; the composable reading it is invalidated only when the result differs.
It is not a cache. It is a narrowing of what invalidates what, and that distinction decides when it helps and when it does nothing.
The case this post is about
A "scroll to top" button that appears once you are past the fifth item:
@Composable
fun Feed(items: List<Post>) {
val listState = rememberLazyListState()
val showScrollToTop = listState.firstVisibleItemIndex > 5 // read in composition
Box {
LazyColumn(state = listState) { items(items, key = { it.id }) { PostRow(it) } }
if (showScrollToTop) ScrollToTopButton(listState)
}
}
And in the same screen, a character counter:
@Composable
fun Composer(vm: ComposerViewModel) {
val text by vm.text.collectAsStateWithLifecycle()
val remaining = 280 - text.length
val overLimit = remaining < 0
Column {
TextField(text, vm::onTextChange)
Text("$remaining", color = if (overLimit) Red else Grey)
SendButton(enabled = !overLimit && text.isNotEmpty())
}
}
Both read fine. One of them is a performance problem and one of them is not, and knowing which is the whole point.
The symptom
Scroll jank in the feed, worse than the list itself warranted:
Layout Inspector, scroll 60 items
composable recompositions skips
Feed 412 0 ← ~7 per scrolled item
LazyColumn 412 0
ScrollToTopButton 2 410
Feed recomposing 412 times for a boolean that changed twice. And because Feed recomposes, its
whole body is re-executed — including the LazyColumn call.
The composer was fine. 280 recompositions for 280 keystrokes, which is correct: the text genuinely changed each time and the counter genuinely needed redrawing.
That contrast is the thing worth understanding. Both compute a derived value in composition; only one of them is wasteful.
Why the obvious fix failed
"Wrap it in remember." The reflex, and it is wrong in an instructive way:
val showScrollToTop = remember { listState.firstVisibleItemIndex > 5 } // frozen at 5? no — frozen forever
No keys, so it computes once and never updates. The button never appears. remember caches across
recompositions; the problem is the number of recompositions.
"Add the index as a key." Now it recomputes on every index change, which is every scrolled item — the same invalidation, plus the bookkeeping:
val show = remember(listState.firstVisibleItemIndex) { listState.firstVisibleItemIndex > 5 }
Reading the index to pass it as a key is still reading it in composition. The read is what subscribes you.
"Hoist it to the ViewModel." LazyListState lives in the UI layer and is not available there.
Some derived values do belong in a ViewModel; scroll position is not one of them.
"Wrap derivedStateOf around the composer's counter too." We did, and it made no difference at
all — which confused me until I understood the mechanism. The counter's result changes on every
keystroke, so narrowing invalidation to the result narrows nothing.
The actual mechanism
Compose's snapshot system does automatic dependency tracking.
Reading a State inside a composable records a subscription. When that State's value changes,
every composable that read it is marked for recomposition. There is no declaration — the read is
the subscription.
derivedStateOf { } creates a new State whose value is computed from other State reads.
Its own subscribers are invalidated only when its computed value changes, compared by equals.
So the shape is:
without derivedStateOf:
firstVisibleItemIndex changes → Feed invalidated → 412 recompositions
with derivedStateOf:
firstVisibleItemIndex changes → the derived computation re-runs (cheap)
→ result unchanged → NO invalidation
→ result changed (twice) → 2 recompositions
The computation still runs on every source change. What changes is who gets invalidated.

Which gives the test for when it helps:
derivedStateOfpays off when the source changes much more often than the result. A high-frequency source narrowed to a low-frequency result is the win. If the result changes as often as the source, it does nothing.
Scroll index (every frame) → boolean (twice): a large win. Text (every keystroke) → character count (every keystroke): nothing.
The sub-case that silently reverts the win: derivedStateOf must be wrapped in remember.
val showButton by derivedStateOf { listState.firstVisibleItemIndex > 0 } // no-op
val showButton by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } // correct
Without remember, a new derived state is constructed on every recomposition. Each one reads the
source, so the subscription is re-established to the current composition — which is precisely the
direct read the first line was supposed to replace. The gain goes to zero.
What makes this expensive to catch is that nothing breaks. The boolean is still correct, the button
still appears at the right moment, and the only symptom is the recomposition count staying where it
was. Reviewers read the line, see derivedStateOf, and tick it.
And the related wrong tool, which is worth naming because the symptom is the same: when the
consumer is an effect rather than a composable — logging, analytics, a scroll-triggered fetch —
derivedStateOf is not the mechanism. snapshotFlow is:
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex > 0 }
.distinctUntilChanged()
.collect { analytics.scrolledPastFold(it) }
}
The distinction is the one in this post's title. derivedStateOf narrows who recomposes;
snapshotFlow turns snapshot reads into a stream for code that is not composing at all. Using the
first where you need the second gives you a value nobody is subscribed to.
The fix
Narrow the feed's invalidation; leave the composer alone.
@Composable
fun Feed(items: List<Post>) {
val listState = rememberLazyListState()
// The read of firstVisibleItemIndex happens INSIDE the derived computation,
// so Feed is subscribed to the boolean, not to the index.
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Box {
LazyColumn(state = listState) { items(items, key = { it.id }) { PostRow(it) } }
if (showScrollToTop) ScrollToTopButton(listState)
}
}
Three details that are easy to get wrong:
remember { derivedStateOf { … } }, not bare derivedStateOf { … }. Without remember, a new
derived state is created on every recomposition, which allocates and loses the previous value. The
pairing is always both.
No keys on the remember. The derived state tracks its own dependencies through the snapshot
system — that is the whole mechanism. Adding keys would reintroduce a read in composition.
The by delegate reads the derived value, which is what subscribes Feed to the boolean.
And the composer keeps its direct computation, deliberately:
// NO derivedStateOf here: `remaining` changes on every keystroke, so there is
// nothing to narrow. Adding it would allocate a State object for no benefit.
val remaining = 280 - text.length
Writing that comment is worth the two lines — the next reader will otherwise "improve" it.
A better fix for the feed is also available and worth knowing, because it narrows further:
// Push the state read down to the smallest composable that needs it.
Box {
LazyColumn(state = listState) { … }
ScrollToTopButton(listState, visible = { listState.firstVisibleItemIndex > 5 })
}
Passing a lambda defers the read into the child, so only the button is invalidated and Feed
never recomposes at all. Deferred reads and derivedStateOf solve the same problem from two
directions; for a value used in one place, deferring is simpler.
How we proved it
Recomposition counts, same scroll:
composable before (recomp/skips) after
Feed 412 / 0 2 / 0
LazyColumn 412 / 0 2 / 410
ScrollToTopButton 2 / 410 2 / 410
Feed from 412 to 2 is the measurement. Note LazyColumn now skips 410 times — it is no longer
re-executed, because its parent is not.
Frame timing:
scroll 60 items, Pixel 6a
before after
frame P50 7.4ms 6.2ms
frame P95 38.6ms 8.0ms
jank frames 4.1% 0.1%
That the computation still runs, which is the part people get wrong about derivedStateOf:
@Test
fun derivedComputationRunsOnEverySourceChangeButInvalidatesOnlyOnResultChange() {
var computations = 0
var recompositions = 0
val index = mutableStateOf(0)
composeTestRule.setContent {
val show by remember { derivedStateOf { computations++; index.value > 5 } }
recompositions++
Text(if (show) "yes" else "no")
}
repeat(20) { index.value = it } // 20 source changes
composeTestRule.waitForIdle()
assertTrue(computations >= 20) // the computation ran every time
assertEquals(2, recompositions) // the reader recomposed twice
}
That test is the one I would keep, because it encodes the mechanism rather than the outcome — and it is what makes the "it is not a cache" claim concrete.
And the negative result, reported because it is informative:
composer with 280 keystrokes
direct computation: 280 recompositions, frame P95 7.9ms
with derivedStateOf: 280 recompositions, frame P95 8.1ms
Marginally worse, within noise, plus an allocation. derivedStateOf where the result changes as
often as the source is pure overhead.
One limit: the deferred-lambda alternative is better for this specific case, and I have shown both
because derivedStateOf is the right tool when the derived value is read in several places or feeds
further computation. Choosing between them is about how many readers there are, not about which is
more advanced.
What this generalizes to
Automatic dependency tracking subscribes you to what you read. In Compose, MobX, Vue, Svelte and every signal-based system, the read is the subscription. Performance work means noticing where a read happens, because that is what determines the invalidation scope.
Narrow the invalidation, or defer the read. Two ways to reduce how much re-runs: compute the low-frequency result and subscribe to that, or push the read down to the component that needs it. Both are about scope, not about caching.
An optimisation that does nothing is still a cost. derivedStateOf on a value that changes as
often as its source allocates an object and adds indirection for no benefit. Measure before and
after, and be willing to report "no change" and revert.
Know whether a tool caches or narrows. remember caches a value across recompositions;
derivedStateOf narrows who gets invalidated. Reaching for the wrong one produces either stale
state or no improvement, and both failures look like the tool not working.
Related
- remember is not a cache, and the key is the whole contract — the other half of this pair
- Recomposition is not re-rendering, and that is why your list stutters — what a recomposition costs
- State hoisting has a limit, and you have probably passed it — where a state read belongs
Keep reading
This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".