Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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,
.claude/skills/skydoves-deferring-state-reads/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-06 | ✗→✓ | ▲ Improved | 119% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 158% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 164% | 0% |
| case-10 | ✓→✓ | = Same ✓ | 139% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 186% | 0% |
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.
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).val y = scrollState.value) and the parent recomposes on every pixel of scroll.@TraceRecomposition log shows the parent's recomposition counter incrementing once per animation tick.Float parameter across composables.../../stability/diagnosing-compose-stability/SKILL.md first.../choosing-derivedstateof/SKILL.md.references/three-phases.md before doing the migration.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.@TraceRecomposition (see ../debugging-recompositions/SKILL.md if it exists in this repo, otherwise the skydoves compose-stability-analyzer runtime).state.value, by state, animatedFloat.value, scrollState.value, dragOffset.value in a composable body. Note which modifier consumes it.| 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.
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.() -> 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.@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.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.kotlin// 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.
kotlin// 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.
kotlin// 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.
kotlin// 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:
kotlin// 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() } )
kotlin// 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.
kotlin// 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.
kotlin// 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.
kotlin// 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.
drawBehind / drawWithCachekotlin// WRONG val color = animatedColor.value Box(Modifier.background(color)) // WRONG because: Modifier.background reads color in Composition; per-frame color animation invalidates the subtree.
kotlin// RIGHT — Draw phase only, no caching needed for a solid color Box(Modifier.drawBehind { drawRect(animatedColor.value) })
kotlin// 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.
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.
| Phase | What it does | Cost when invalidated | |---|---|---| | Composition | Run @Composable functions, build/diff the UI tree | Highest — all skippability gates re-run, child composables potentially recompose | | Layout | measure() and placeRelative() for each node | Medium — re-measure and re-place affected subtree | | Draw | Record draw commands into the canvas | Lowest — single render pass |
| Modifier | Phase 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.
Modifier.graphicsLayer { … } for animated alpha, scale, rotate, translation. Never Modifier.alpha(state.value) / Modifier.rotate(state.value) / Modifier.scale(state.value) for hot state..value read in a composable body is one fewer Composition invalidation per frame.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.() -> T) over plain values when the value is hot and crosses composable boundaries. Pair with lambda modifiers at the receiving end.Modifier.drawWithCache { } over Modifier.drawBehind { } when the per-frame work involves rebuildable resources (paths, brushes, gradients) that depend on size or theme.@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.Modifier.alpha(/Modifier.rotate(/Modifier.scale(/Modifier.offset(<dp expr>) patterns fed by hot state.() -> T lambda providers, not as T values.dp vs px confusion: Modifier.offset { IntOffset(x, y) } takes pixels).references/three-phases.md — detailed walkthrough of Composition / Layout / Draw, downward invalidation, the backwards-write rule, and the comprehensive modifier-phase cheat sheet.Other measured skills in the registry, with their headline benchmark lift.