Skip to content

Instantly share code, notes, and snippets.

@Digipom
Last active May 22, 2026 16:01
Show Gist options
  • Select an option

  • Save Digipom/f7708a3cb01e38f221a07fea0b04ae9e to your computer and use it in GitHub Desktop.

Select an option

Save Digipom/f7708a3cb01e38f221a07fea0b04ae9e to your computer and use it in GitHub Desktop.
compose-state-patterns
This is a Compose skill we created and started using in February 2026. It specifically covers state patterns in Compose. This may no longer be needed once a similar skill is officially available from Google.
For the Google skills, see:
https://github.com/android/skills/
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.

Compose State Patterns

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:


Core Concepts

Compose's Three Phases

Compose updates UI in three sequential phases:

  1. Composition — runs composable functions, builds/updates the UI tree
  2. Layout — measures and places elements
  3. 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.

State Reads Drive Recomposition

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.


Pattern 1: rememberUpdatedState — Stable Reference, Latest Value

What It Does

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)

When to Use

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 NOT to Use

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

Pattern 2: derivedStateOf — Reduce Recomposition Frequency

What It Does

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().

The Key Rule

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

When to Use

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.

The Non-State Variable Trap

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.

When NOT to Use

  • 1:1 input-to-output ratio. If output changes just as often as input, derivedStateOf adds 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. Use remember(key) { computation } instead.

Important: Always Wrap in remember Inside Composables

derivedStateOf must be wrapped in remember inside composable functions — just like mutableStateOf. Without remember, it is reallocated on every recomposition, defeating its purpose.

derivedStateOf vs remember(key)

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.

Pattern 3: Deferred State Reads (Lambda Pattern)

What It Does

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.

Two Levels of Benefit

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.

Material 3 Lambda Overloads

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.

When to Use Lambda vs Val Parameters

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

Combined Pattern: Stable Object with Fresh State

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) creates MyHelper exactly once.
  • rememberUpdatedState provides State objects that update in place.
  • Lambdas close over the State objects, so they always read the latest values.
  • No object recreation, no restarting effects, no stale captures.

When to Use rememberUpdatedState vs Direct Snapshot State Reads in Lambdas

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.

Common Mistake: Using remember(changingValue) with a Resolved Value

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


Decision Guide

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 }

Anti-Patterns to Flag in Code Review

  1. remember(frequentlyChangingValue) { Object(value) } — Recreates the object on every change. Use remember (no keys) + lambda + rememberUpdatedState.

  2. derivedStateOf with 1:1 input/output ratio — Adds overhead with no filtering benefit. Just read the state directly.

  3. derivedStateOf reading non-State variables without keys on remember — The non-State variable is captured once and never updates. Add it as a key to remember.

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

  5. Using Modifier.offset(x, y) with animated/dragged values instead of Modifier.offset { } — Forces recomposition when the value could be handled purely in the layout phase.

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

  7. 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 the State itself.

  8. Returning a new stateful helper/holder object without remember at 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 than remember(key) — it's unconditional churn. Use remember { … } (no keys) when the object should be stable and read changing values via lambdas/rememberUpdatedState; use remember(key1, key2) { … } only when the object must be recreated when those keys change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment