derivedStateOf: The Compose API Everyone Reaches for Too Late
derivedStateOf converts a high-frequency input into a low-frequency output — turning 60 recompositions per second into one. When it's the right tool, when remember(key) is what you actually wanted, and the mistakes that make it a no-op.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — derivedStateOf exists for one job: when a state value changes far more often than the
thing you compute from it. The canonical case is scroll position — firstVisibleItemIndex updates
continuously, but index > 0 flips once. Wrapping the calculation means recomposition happens when
the result changes, not the input. It is about change frequency, not computation cost — for
cost, you want remember(key).
The bug that doesn't look like a bug
@Composable
fun Feed(listState: LazyListState) {
// Reads a value that changes on every scroll frame.
val showScrollToTop = listState.firstVisibleItemIndex > 0
Column {
LazyColumn(state = listState) { /* ... */ }
if (showScrollToTop) ScrollToTopButton()
}
}
This is correct and it performs badly. Reading firstVisibleItemIndex during composition subscribes
this composable to it. The value changes many times per second while scrolling, so Feed recomposes
many times per second — to recompute a boolean that changes once during the entire gesture.
Everything inside Feed that isn't skippable re-runs with it. On a complex screen this is a
meaningful chunk of your frame budget, and it looks like "Compose is slow" rather than "I subscribed
to the wrong thing".
The fix
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
derivedStateOf creates a State whose value is computed from other state reads. The calculation
re-runs whenever its inputs change — that part is unavoidable and cheap — but the derived state only
notifies its readers when the computed result actually differs.
The result: the lambda runs 60 times a second, the boolean is compared 60 times a second, and your composable recomposes twice during the whole scroll.
The mental model
derivedStateOfconverts a high-frequency input into a low-frequency output.
That single sentence tells you every correct use:
// Scroll position → a boolean
val isScrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
// Continuous text input → a validity flag
val isValid by remember { derivedStateOf { email.contains("@") && password.length >= 8 } }
// A large changing list → a small summary
val hasSelection by remember { derivedStateOf { items.any { it.selected } } }
In each case the input churns and the output is stable.
When not to use it
When the input changes rarely. If user updates once per screen load, wrapping
user.name.uppercase() in derivedStateOf adds an object, an observer, and indirection to save
nothing. The overhead is small but the confusion isn't — a reader now assumes there's a performance
reason and goes looking for it.
When you meant remember(key). This is the most common confusion:
// Wrong tool: this is about COST, not frequency.
val sorted by remember { derivedStateOf { items.sortedBy(Item::name) } }
// Right tool: recompute only when items changes.
val sorted = remember(items) { items.sortedBy(Item::name) }
derivedStateOf will happily re-run that sort on every input change and then compare two large lists
to decide whether to notify — you've added work, not removed it. Use remember(key) when you want to
avoid recomputing; use derivedStateOf when you want to avoid notifying.
When the output changes as often as the input. If you're deriving the scroll offset itself, every input change produces a new output. There's nothing to filter, and you've added a layer.
The mistake that makes it a no-op
// Missing remember — rebuilt every recomposition, so it never filters anything.
val showButton by derivedStateOf { listState.firstVisibleItemIndex > 0 }
Without remember, a fresh derived state is created on each recomposition, with no previous value to
compare against. The code looks right, the behaviour is unchanged, and the performance problem you
were fixing is still there. Compose's lint will flag this — don't suppress it.
Verifying you fixed it
Don't trust reasoning here; measure. Add the recomposition counter from the Compose Layout Inspector, or drop in a debug side effect:
SideEffect { Log.d("recompose", "Feed recomposed") }
Scroll the list before and after. The log should go from a stream to a couple of lines. If it
doesn't, you either missed a remember or something else in the composable is reading the scroll
state directly.
The test to apply
Does the input change far more often than the output?
Yes → derivedStateOf. No → leave it alone, or reach for remember(key) if the cost is the problem.
Most misuse comes from treating it as a general-purpose optimisation rather than the narrow,
frequency-filtering tool it actually is.