deferring-state-reads — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited deferring-state-reads (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
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.
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..value and by state.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.// 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.
// 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()
}
)// 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.
// 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.
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.
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.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.