| name | compose-state-patterns |
|---|---|
| description | ALWAYS use this skill when writing, reviewing, or discussing ANY Jetpack Compose code that involves state, remember, recomposition, or performance. Covers rememberUpdatedState, derivedStateOf, deferred state reads (lambda pattern), and when to use each. Trigger on: remember with keys, mutableStateOf, rememberUpdatedState, derivedStateOf, State<T>, helper/holder objects in Compose, slider/drag/scroll/animation state, () -> T vs T parameter design, object recreation in remember blocks, or any Compose recomposition concern. |
Patterns for managing state reads, avoiding unnecessary recomposition, and reducing object allocation in Jetpack Compose. All guidance is derived from official Android developer documentation and blog posts by Googlers.
Sources:
- https://developer.android.com/develop/ui/compose/side-effects (rememberUpdatedState, derivedStateOf)
- https://developer.android.com/develop/ui/compose/performance/bestpractices (deferred reads, lambda modifiers)
- https://developer.android.com/develop/ui/compose/performance/phases (Compose phases, skipping phases)
- https://medium.com/androiddevelopers/jetpack-compose-when-should-i-use-derivedstateof-63ce7954c11b (Ben Trengrove, Google)
Compose updates UI in three sequential phases:
- Composition — runs composable functions, builds/updates the UI tree
- Layout — measures and places elements
- Drawing — renders elements to screen
Compose can skip phases that aren't needed. Many performance optimizations work by pushing state reads into later phases so earlier phases can be skipped entirely.
When a State<T> object is read during a particular phase, changes to that state invalidate that phase. Where and when you read state determines what work Compose must redo.
Wraps a value in a State<T> whose identity is stable across recompositions but whose .value is reassigned to the latest input each recomposition — so long-lived lambdas and effects always read the current value without themselves being recreated.
val currentOnTimeout by rememberUpdatedState(onTimeout)Use case A: Long-lived effects. When you need the latest value inside a LaunchedEffect or DisposableEffect without restarting the effect.
// From official docs — LandingScreen timeout example
@Composable
fun LandingScreen(onTimeout: () -> Unit) {
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Effect matches lifecycle of LandingScreen — does NOT restart
// when onTimeout changes, but always calls the latest version.
LaunchedEffect(Unit) {
delay(SplashWaitTime)
currentOnTimeout()
}
}The rule from the docs: "Variables used in an effect should be added as a parameter of the effect composable, or use rememberUpdatedState." Use the former when you want the effect to restart on change; use the latter when you do not.
Use case B: Stable capture target for lambdas in long-lived objects. When you create an object once with remember (no keys) and need its lambdas to always read fresh state. The rememberUpdatedState creates a State that the lambda closes over. The lambda identity is stable; every invocation reads the current snapshot value.
val displayValue by rememberUpdatedState(computedValue)
return remember {
MyHelper(
value = { displayValue }, // lambda captures the State, not the Float
onEvent = { /* ... */ }
)
}- When you actually want the effect or consumer to restart/recreate in response to changes — pass the value as a key to the effect instead.
- For simple composition-level reads where no long-lived scope is involved — just read the value directly.
Creates a State<T> whose calculation block runs whenever any snapshot state read inside it changes, but which only triggers recomposition when the result differs from the previous result. Analogous to distinctUntilChanged().
From the official side-effects docs: "Caution: derivedStateOf is expensive, and you should only use it to avoid unnecessary recomposition when a result hasn't changed."
From Ben Trengrove (Google): "derivedStateOf is used when your state or key is changing more than you want to update your UI. If you don't have a difference in the amount of input compared with output, you don't need to use it."
Only when input changes more often than the derived output changes.
// CORRECT — scroll position changes every frame, but the boolean
// only flips once (when first item scrolls off screen).
val listState = rememberLazyListState()
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
ScrollToTopButton()
}Without derivedStateOf, every scroll tick would trigger recomposition. With it, recomposition only happens when the boolean actually flips.
derivedStateOf only reacts to snapshot state reads performed inside its block (e.g. State<T>, SnapshotStateList, SnapshotStateMap). Plain values (like an Int parameter) are captured once at creation time and won't trigger invalidation. If you need a non-snapshot value in the calculation, pass it as a key to remember:
// CORRECT — threshold is a key, listState is a State object
val showButton by remember(threshold) {
derivedStateOf {
listState.firstVisibleItemIndex > threshold
}
}Without the key, changing threshold would have no effect because derivedStateOf captured the initial value.
- 1:1 input-to-output ratio. If output changes just as often as input,
derivedStateOfadds overhead for zero benefit. Ben Trengrove: "derivedStateOf is not doing anything and is just causing a small overhead." - Combining multiple states that all change at similar rates. You probably want recomposition when any of them change.
- As a replacement for
remember(key)when you just want to cache a computed value that recalculates when inputs change. Useremember(key) { computation }instead.
derivedStateOf must be wrapped in remember inside composable functions — just like mutableStateOf. Without remember, it is reallocated on every recomposition, defeating its purpose.
These look similar but serve different purposes:
remember(key) { calculation }— reruns calculation when key changes. No "distinct until changed" filtering; the new result is used regardless of whether it differs from the previous one.remember { derivedStateOf { calculation } }— recalculates when any read State changes, but only triggers recomposition when the result differs from the previous result. Output changes less often than input.
Instead of passing a resolved value, pass () -> T. The consumer calls the lambda only when it actually needs the value, deferring the state read to where (and when) it's used.
Level 1: Narrow the recomposition scope. If state is hoisted high in the tree, passing it as a lambda to a child removes the parent's direct state read, which can prevent the parent from being invalidated by that state change and narrow recomposition to where the value is actually read.
// BEFORE — reading scroll.value here invalidates SnackDetail (Box is inline, not a recomposition scope)
@Composable
fun SnackDetail() {
Box(Modifier.fillMaxSize()) {
val scroll = rememberScrollState(0)
Title(snack, scroll.value) // state read here
}
}
// AFTER — state read deferred to Title
@Composable
fun SnackDetail() {
Box(Modifier.fillMaxSize()) {
val scroll = rememberScrollState(0)
Title(snack) { scroll.value } // lambda, read deferred
}
}
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
val offset = with(LocalDensity.current) { scrollProvider().toDp() }
Column(modifier = Modifier.offset(y = offset)) { /* ... */ }
}Level 2: Can skip composition entirely. If the state only affects layout or drawing, use lambda-based modifiers so Compose can skip the composition phase for that state change.
// BEST — state read pushed to the layout phase via lambda modifier
@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
Column(
modifier = Modifier.offset { IntOffset(x = 0, y = scrollProvider()) }
) { /* ... */ }
}From the docs: "When you are passing frequently changing State variables into modifiers, you should use the lambda versions of the modifiers whenever possible." Examples: Modifier.offset { } instead of Modifier.offset(x, y), drawBehind { } for color/draw changes.
Some Material 3 components provide lambda overloads for frequently-updated values to reduce recomposition. For example, the progress: Float overloads on LinearProgressIndicator and CircularProgressIndicator are deprecated in favor of progress: () -> Float:
LinearProgressIndicator(
progress = { currentProgress }, // () -> Float, not Float
)This can reduce recomposition during progress animations, since the value is read only when needed for drawing rather than forcing composition on every update. Not all M3 components follow this pattern — it applies where values are expected to change rapidly.
Use () -> T (lambda) when:
- The value changes frequently (animations, scrolling, dragging, progress)
- The consumer is a long-lived object created once and not recreated per value change
- You want to skip recomposition and let the value be read in layout or draw phase
- You're designing a composable API where callers may pass rapidly-changing state
Use T (val) when:
- The value changes infrequently or discretely (a selected tab index, a toggle state)
- A change in value should trigger recomposition of the consumer (the consumer's UI tree structure depends on the value)
- Simplicity is more important than optimization — don't prematurely optimize
This is the idiomatic pattern for creating a helper/holder object once while keeping its state reads current:
@Composable
fun rememberMyHelper(
value: Float,
onValueChange: (Float) -> Unit,
): MyHelper {
// 1. Wrap callbacks in rememberUpdatedState so lambdas always call latest
val currentOnValueChange by rememberUpdatedState(onValueChange)
// 2. Wrap plain parameters in rememberUpdatedState — they are NOT snapshot
// state, so they'd be captured once and go stale without this.
val currentValue by rememberUpdatedState(value)
// 3. Create the object ONCE — no keys on remember
return remember {
MyHelper(
value = { currentValue }, // lambda reads fresh State
onValueChange = { currentOnValueChange(it) } // lambda reads fresh State
)
}
}Why this works:
remember(no keys) createsMyHelperexactly once.rememberUpdatedStateprovidesStateobjects that update in place.- Lambdas close over the
Stateobjects, so they always read the latest values. - No object recreation, no restarting effects, no stale captures.
rememberUpdatedState is for values that are not already snapshot state — plain parameters (Float, Int, callbacks) that would be captured once at remember creation time and go stale. It wraps them in a State<T> so lambdas can read the latest value at invocation time.
If you already have snapshot state (mutableStateOf, mutableFloatStateOf, etc.), prefer reading it directly in the lambda rather than copying its current .value into another State during composition. The snapshot system ensures the lambda reads the latest value when invoked — but note that whether this avoids recomposition depends on where the lambda runs (e.g., during layout/draw via a lambda modifier, or in an event handler). A lambda invoked during composition will still cause a composition-time read.
// Assume: isDragging: State<Boolean>, scrubValue: FloatState, value: Float (plain param)
// BAD — reads scrubValue during composition to feed into rememberUpdatedState,
// causing recomposition every frame during a drag.
val displayValue = if (isDragging.value) scrubValue.floatValue else value // composition-time read!
val currentDisplayValue by rememberUpdatedState(displayValue)
return remember {
MyHelper(value = { currentDisplayValue }, ...)
}
// GOOD — only wrap the plain parameter; read snapshot state directly in the lambda.
val currentValue by rememberUpdatedState(value) // value is a plain Float param
return remember {
MyHelper(
value = { if (isDragging.value) scrubValue.floatValue else currentValue }, // deferred read
...
)
}In the bad version, every change to scrubValue triggers recomposition because it's read in the composition scope. In the good version, isDragging and scrubValue (both snapshot state) are read only when the lambda is invoked — if MyHelper invokes this lambda outside composition (e.g., during layout/draw or in response to events), drag updates won't invalidate the composition scope.
Rule of thumb: rememberUpdatedState is a bridge for keeping a long-lived consumer up to date without recreating it. Prefer it for plain params, callbacks, and occasional computed values — but avoid feeding it values produced by composition-time reads of rapidly-changing snapshot state when you could defer the read.
// BAD — recreates MyHelper on every change to displayValue
return remember(displayValue) {
MyHelper(value = displayValue, ...)
}This defeats the purpose. If displayValue changes 60 times per second, you allocate 60 objects per second. Use the lambda + rememberUpdatedState pattern instead.
| Situation | Tool |
|---|---|
| Input state changes more often than you need to react | derivedStateOf |
| Need latest value in a long-lived effect without restarting it | rememberUpdatedState |
| Need a stable object/lambda that always reads fresh state | rememberUpdatedState + () -> T lambda |
| State only affects layout or drawing, not composition structure | Lambda modifier (offset {}, drawBehind {}) |
| Designing a composable API that receives frequently-changing values | Accept () -> T parameter (as M3 does for progress indicators) |
| Value changes infrequently and should trigger recomposition | Plain val parameter, remember(key) |
| Caching an expensive computation that recalculates on input change | remember(key) { computation } |
-
remember(frequentlyChangingValue) { Object(value) }— Recreates the object on every change. Useremember(no keys) + lambda +rememberUpdatedState. -
derivedStateOfwith 1:1 input/output ratio — Adds overhead with no filtering benefit. Just read the state directly. -
derivedStateOfreading non-State variables without keys on remember — The non-State variable is captured once and never updates. Add it as a key toremember. -
Reading rapidly-changing state high in the tree when only a child needs it — Causes the entire parent scope to recompose. Pass the state read as a lambda to the child.
-
Using
Modifier.offset(x, y)with animated/dragged values instead ofModifier.offset { }— Forces recomposition when the value could be handled purely in the layout phase. -
Writing to state that has already been read in the same composition ("backwards write") — Can cause recomposition to occur on every frame, endlessly. Always write to state in response to events (in lambdas like
onClick), never during composition. -
Reading rapidly-changing snapshot state in composition just to feed it to
rememberUpdatedState(e.g.,val x = state.value; val currentX by rememberUpdatedState(x)) — This defeats deferral by forcing a composition-time read. Prefer reading the snapshot state directly in the deferred lambda, or passing theStateitself. -
Returning a new stateful helper/holder object without
rememberat all (e.g.,return DragIgnoreHelper(value = displayValue, ...)directly in a composable) — Allocates a new object on every recomposition, not just when values change. This is worse thanremember(key)— it's unconditional churn. Useremember { … }(no keys) when the object should be stable and read changing values via lambdas/rememberUpdatedState; useremember(key1, key2) { … }only when the object must be recreated when those keys change.