5 min read

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.

AndroidJetpack ComposePerformanceRecompositionKotlin
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DRderivedStateOf 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

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

Keep reading