agentsclimarketplace

Deferring state reads

Skill skydoves/compose-performance-skills/recomposition/deferring-state-reads

⚡️ A curated library of Agent Skills focused on Jetpack Compose performance.

Install
npx -y skills add skydoves/compose-performance-skills --skill deferring-state-reads

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

What its author says it does

Copied from the file, not written here

Use this skill to push frequently-changing Jetpack Compose state reads (scroll position, animation values, drag offsets) out of the Composition phase and down into Layout or Draw using lambda-based modifiers like Modifier.offset { }, Modifier.layout { }, Modifier.graphicsLayer { }, Modifier.drawBehind { }, and Modifier.drawWithCache { }. Covers the three-phase model (Composition, Layout, Draw), why a state read at phase N invalidates phase N and every phase below, the modifier-phase cheat sheet, and lambda providers (() -> T) for hoisting hot values across composables. Use when the developer mentions every-frame work, scroll jank, animation jank, dropped frames, animated alpha or offset, "the whole subtree recomposes on scroll", Modifier.alpha(state.value), Modifier.offset(x.dp), or graphicsLayer.

The file declares its own license as Apache-2.0. See LICENSE for complete terms.. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

14.8 KB, as published. Nobody here has run it

Deferring State Reads — Move Hot Reads from Composition to Draw

Compose runs three phases per frame — Composition → Layout → Draw. A state read at phase N invalidates phase N and every phase below it. The single biggest perf win in any animation- or scroll-driven UI is moving a state read from Composition down to Layout or Draw via a lambda-based modifier. This skill teaches Claude how to spot the wrong-phase read and migrate it.

When to use this skill

  • An animation triggers full subtree recomposition on every frame (Modifier.alpha(progress.value), Modifier.offset(x.dp), Modifier.padding(state.dp)padding has no lambda overload, so use Modifier.layout { ... } or Modifier.offset { IntOffset(...) } instead when the inset is animated).
  • A scroll position is read directly in a composable body (val y = scrollState.value) and the parent recomposes on every pixel of scroll.
  • The developer says "every frame", "scroll jank", "animation jank", "dropped frames", or reports the whole screen recomposing on drag.
  • A @TraceRecomposition log shows the parent's recomposition counter incrementing once per animation tick.
  • The developer is passing a hot value (animation progress, scroll offset, drag delta) as a Float parameter across composables.

When NOT to use this skill

  • The state changes once per user interaction (button click, dialog open) — the lambda-modifier rewrite buys nothing and adds noise.
  • The state read genuinely needs to drive Composition (show/hide a different composable, swap a different component tree). Lambda modifiers cannot decide which composable to emit.
  • The non-skippable parent is non-skippable for a different reason (unstable parameter). Diagnose with ../../stability/diagnosing-compose-stability/SKILL.md first.
  • The developer wants to filter many high-frequency inputs into one rare boolean — that is ../choosing-derivedstateof/SKILL.md.

Prerequisites

  • Familiarity with the three-phase model. If unsure which phase a read happens in, read references/three-phases.md before doing the migration.
  • Compose UI 1.7+ for rememberGraphicsLayer() and the modern Modifier.animateItem() shape. Lambda forms of Modifier.offset { }, Modifier.graphicsLayer { }, Modifier.drawBehind { }, and Modifier.drawWithCache { } exist on every supported Compose version.
  • Ability to confirm recomposition counts in Layout Inspector or via @TraceRecomposition (see ../debugging-recompositions/SKILL.md if it exists in this repo, otherwise the skydoves compose-stability-analyzer runtime).
  • A release build for any final measurement — debug builds run interpreted and lie about cost.

Workflow

  • 1. Identify the state read site and the modifier consuming it. Look for state.value, by state, animatedFloat.value, scrollState.value, dragOffset.value in a composable body. Note which modifier consumes it.

  • 2. Classify the modifier — value form or lambda form. Most positioning/sizing/drawing modifiers ship in two flavors:

Value form (read in Composition)Lambda form (read in Layout or Draw)
Modifier.offset(x = dp, y = dp)Modifier.offset { IntOffset(x, y) }
Modifier.padding(start = dp, …)Modifier.layout { measurable, constraints -> … } (no lambda overload of padding; use a custom layout to inset with hot state)
Modifier.size(dp)Modifier.layout { … } (custom)
Modifier.alpha(float)Modifier.graphicsLayer { alpha = … }
Modifier.rotate(float)Modifier.graphicsLayer { rotationZ = … }
Modifier.scale(float)Modifier.graphicsLayer { scaleX = …; scaleY = … }
Modifier.background(color)Modifier.drawBehind { drawRect(color) }

If a value form is being fed a hot state, swap to the lambda form. The lambda is invoked on every Layout (or Draw) pass without re-running Composition.

  • 3. Migrate value-form modifiers to lambda form. The lambda body re-executes when the read state changes, but it does so during Layout or Draw — Composition is never invalidated.

  • 4. For multiple per-frame transforms, hoist into a single Modifier.graphicsLayer { … } block. It covers translationX/translationY, scaleX/scaleY, rotationZ, alpha, cameraDistance, clip, and shape in one Draw-phase node, and is preferred over chaining alpha + offset + rotate.

  • 5. Hoist hot values across composables as lambda providers. Pass () -> Float instead of Float so the receiving composable can defer the read into its own lambda modifier without invalidating its caller. This is the single most important cross-composable perf trick after lambda modifiers themselves.

  • 6. Verify in Layout Inspector / @TraceRecomposition. The parent composable's recomposition count MUST stop incrementing per animation frame. If it still climbs, the wrong-phase read survived elsewhere — re-grep the file for .value and by state.

  • 7. For per-frame Draw work that is more than a transform, use Modifier.drawBehind { } or Modifier.drawWithCache { }. drawBehind re-runs every Draw pass; drawWithCache caches the build step (e.g. paths, brushes) and only re-runs the onDraw block on state change.

Patterns

Pattern: Animated offset

// WRONG
val animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset(x = animatedX.value.dp))
// WRONG because: reading .value in Composition phase invalidates the whole subtree on every animation frame.
// RIGHT
val animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset { IntOffset(animatedX.value.toInt(), 0) })

The lambda runs in Layout, so Composition is never invalidated. The animation still drives the visual position via Layout-only invalidation.

Pattern: Animated alpha (and other transforms)

// WRONG
val animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.alpha(animatedAlpha))
// WRONG because: Modifier.alpha(Float) reads the value in Composition; the entire Box subtree recomposes every frame.
// RIGHT
val animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.graphicsLayer { alpha = animatedAlpha })

graphicsLayer { } reads inside the Draw phase only. For combined transforms, fold them all into the same block:

// RIGHT — one Draw-phase node, three transforms
Box(
    Modifier.graphicsLayer {
        alpha = progress
        scaleX = 1f + 0.2f * progress
        scaleY = 1f + 0.2f * progress
        translationX = progress * 32.dp.toPx()
    }
)

Pattern: Scroll-driven sticky header

// WRONG
val offset = scrollState.value
Header(Modifier.offset(y = offset.dp))
// WRONG because: scrollState.value updates every pixel of scroll, and reading it in the parent body recomposes Header (and every sibling) per scroll tick.
// RIGHT
Header(Modifier.offset { IntOffset(0, scrollState.value) })

The lambda captures scrollState (a stable holder), reads .value only when Layout runs, and isolates invalidation to the Layout phase of Header.

Pattern: Lambda providers across composables

// WRONG
@Composable
fun Parent(scrollOffset: Float) {
    Child(scrollOffset)
}

@Composable
fun Child(scrollOffset: Float) {
    Box(Modifier.offset { IntOffset(0, scrollOffset.toInt()) })
}
// WRONG because: scrollOffset is read in Parent's signature each frame; Parent and every sibling of Child recompose per frame even though only Child cares.
// RIGHT
@Composable
fun Parent(scrollOffset: () -> Float) {
    Child(scrollOffset)
}

@Composable
fun Child(scrollOffset: () -> Float) {
    Box(Modifier.offset { IntOffset(0, scrollOffset().toInt()) })
}

The lambda parameter is a stable function reference. Calling it inside Modifier.offset { } defers the read to Layout. Parent never re-reads the hot state, so Parent never recomposes.

Pattern: Per-frame Draw work — drawBehind / drawWithCache

// WRONG
val color = animatedColor.value
Box(Modifier.background(color))
// WRONG because: Modifier.background reads color in Composition; per-frame color animation invalidates the subtree.
// RIGHT — Draw phase only, no caching needed for a solid color
Box(Modifier.drawBehind { drawRect(animatedColor.value) })
// RIGHT — heavier work; cache the Path/Brush across frames
Box(
    Modifier.drawWithCache {
        val brush = Brush.linearGradient(
            colors = listOf(start, end),
            start = Offset.Zero,
            end = Offset(size.width, size.height),
        )
        onDrawBehind { drawRect(brush, alpha = animatedAlpha.value) }
    }
)

drawWithCache re-builds the cache only when the remember-keys captured by the lambda change; the onDrawBehind block re-runs on every Draw with the latest state.

Three phases reference

Compose runs every frame in this order. A read at phase N invalidates phase N and everything below it. Push reads as low as possible.

PhaseWhat it doesCost when invalidated
CompositionRun @Composable functions, build/diff the UI treeHighest — all skippability gates re-run, child composables potentially recompose
Layoutmeasure() and placeRelative() for each nodeMedium — re-measure and re-place affected subtree
DrawRecord draw commands into the canvasLowest — single render pass

Modifier phase cheat sheet (most common cases)

ModifierPhase the state is read in
Modifier.offset(Dp)Composition
Modifier.offset { IntOffset }Layout
Modifier.padding(Dp) / padding(PaddingValues)Composition (no lambda overload exists)
Modifier.size(Dp)Composition
Modifier.layout { measurable, constraints -> … }Layout (escape hatch when a hot state must drive measurement, e.g. an animated inset — padding ships no lambda form)
Modifier.alpha(Float)Composition
Modifier.rotate(Float)Composition
Modifier.scale(Float)Composition
Modifier.graphicsLayer { … }Draw
Modifier.background(Color)Composition
Modifier.drawBehind { … }Draw
Modifier.drawWithCache { … }Draw (cache rebuilds when its captured state changes)

For the deeper mechanics — invalidation propagation, the backwards-write rule, and a comprehensive per-modifier breakdown — see references/three-phases.md.

Mandatory rules

  • MUST prefer lambda-based modifiers for any state that changes more often than once per user interaction (animation values, scroll position, drag offsets, gesture deltas).
  • MUST use Modifier.graphicsLayer { … } for animated alpha, scale, rotate, translation. Never Modifier.alpha(state.value) / Modifier.rotate(state.value) / Modifier.scale(state.value) for hot state.
  • MUST NOT read frequently-changing state in a composable body when a lambda modifier could read it later. Every avoided .value read in a composable body is one fewer Composition invalidation per frame.
  • MUST NOT write to a MutableState already read in the same composition pass — that is a backwards write (see references/three-phases.md); the runtime aborts the recomposition with a cost.
  • PREFERRED: lambda providers (() -> T) over plain values when the value is hot and crosses composable boundaries. Pair with lambda modifiers at the receiving end.
  • PREFERRED: Modifier.drawWithCache { } over Modifier.drawBehind { } when the per-frame work involves rebuildable resources (paths, brushes, gradients) that depend on size or theme.

Verification

  • Layout Inspector shows the parent composable's recomposition count is 0 (or near 0) per animation frame. Only Layout/Draw counters increment.
  • @TraceRecomposition(traceStates = true) (skydoves/compose-stability-analyzer) confirms in release + R8 + real device that the wrapping composable does not recompose per frame; only the sub-node attached to the lambda modifier re-runs.
  • A grep over the migrated file finds no Modifier.alpha(/Modifier.rotate(/Modifier.scale(/Modifier.offset(<dp expr>) patterns fed by hot state.
  • Cross-composable hot values are passed as () -> T lambda providers, not as T values.
  • The release build still renders correctly — value-form to lambda-form is a behavioral no-op only when the lambda body matches the original arithmetic (watch for dp vs px confusion: Modifier.offset { IntOffset(x, y) } takes pixels).

References

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.