Engineering

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.

MobileByteSensei 8 min read
composeperformanceandroid

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.

Compose's snapshot dependency tracking: a composable reading a scroll index directly is subscribed to the index and invalidated on every frame, while derivedStateOf interposes a new State whose subscribers are invalidated only when the computed boolean differs — with the computation itself still running on every source change

Which gives the test for when it helps:

derivedStateOf pays 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.

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".

Follow by RSS