Deferring State Reads
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.
MCP get_skill({ skillId: "deferring-state-reads-936d94c9" })Use this skill with your agent
Create a free account and connect via MCP
# 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
```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.
### Pattern: Animated alpha (and other transforms)
```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()
}
)
```
### Pattern: Scroll-driven sticky header
```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`.
### Pattern: Lambda providers across composables
```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.
### Pattern: Per-frame Draw work — `drawBehind` / `drawWithCache`
```kotlin
// 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.
## 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.
| 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 cheat sheet (most common cases)
| 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`.
## 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
- Android Developers — Phases of Jetpack Compose: https://developer.android.com/develop/ui/compose/phases
- Android Developers — Performance: phases: https://developer.android.com/develop/ui/compose/performance/phases
- Android Developers — Graphics modifiers: https://developer.android.com/develop/ui/compose/graphics/draw/modifiers
- Android Developers — Custom modifiers (Modifier.Node): https://developer.android.com/develop/ui/compose/custom-modifiers
- Ben Trengrove — Debugging recomposition: https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- Why test perf in release: https://medium.com/androiddevelopers/why-should-you-always-test-compose-performance-in-release-4168dd0f2c71
- Chris Banes — Compose perf tag: https://chrisbanes.me/tags/jetpack-compose-performance/
- skydoves — 6 Jetpack Compose Guidelines: https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- skydoves — Jetpack Compose Mechanism: https://speakerdeck.com/skydoves/jetpack-compose-mechanism
- `references/three-phases.md` — detailed walkthrough of Composition / Layout / Draw, downward invalidation, the backwards-write rule, and the comprehensive modifier-phase cheat sheet.Related Skills
More skills in Mobile App Development
Android Design Guidelines
Material Design 3 and Android platform guidelines. Use when building Android apps with Jetpack Compose or XML layouts, implementing Material You, navigation, or accessibility. Triggers on tasks involving Android UI, Compose components, dynamic color, or Material Design compliance.
Android Java Skill
Android Java development with MVVM, ViewBinding, and Espresso testing
Android Kotlin
Android Kotlin development with Coroutines, Jetpack Compose, Hilt, and MockK testing
Appium Skill
Generates production-grade Appium mobile automation scripts for Android and iOS in Java, Python, or JavaScript. Supports real device and emulator testing locally and on TestMu AI cloud with 100+ real devices. Use when the user asks to automate mobile apps, test on Android/iOS, write Appium tests, or mentions "Appium", "mobile testing", "real device", "app automation". Triggers on: "Appium", "mobile test", "Android test", "iOS test", "real device", "app automation", "UiAutomator", "XCUITest driver", "TestMu", "LambdaTest".
Apple Appstore Reviewer
Serves as a reviewer of the codebase with instructions on looking for Apple App Store optimizations or rejection reasons.
App Rejection Recovery
When the user's app or update was rejected by Apple App Review or Google Play Review and they need to diagnose why, fix it, and resubmit fast. Use when the user mentions "app rejected", "App Review rejection", "guideline violation", "Apple rejected my app", "Google Play rejected", "Play policy violation", "Resolution Center", "metadata rejection", "binary rejection", "guideline 2.1", "guideline 4.3", "guideline 5.1.1", "Sign in with Apple required", "Apple ID rejection", "Play Store suspension", "appeal", "I need to respond to App Review", or "expedited review". For pre-submission listing health, see aso-audit. For metadata-only fixes, see metadata-optimization.
Explore Other Categories
Skills from other categories with shared topics
Auditing Compose Performance
Use this skill to run an end-to-end Jetpack Compose performance audit when the symptom is broad ("the app feels sluggish", "scroll is rough everywhere", "we're starting a perf sprint", "what should we fix first?"). Orchestrates the four-phase Measure → Diagnose → Fix → Verify loop by sequencing the 25 focused skills (release-mode setup, R8, Baseline Profiles, Compose Compiler reports, stability inference, Layout Inspector, `@TraceRecomposition`, stabilization, strong skipping, phase-deferral, derivedStateOf, lazy layouts, lazy prefetch, Modifier.Node, modifier ordering, flow collection, effects, CI gates, hot-reload) and produces a written audit report with Before/After Macrobenchmark numbers. Use when the developer wants a perf sprint kickoff, a pre-release perf gate, onboarding to a perf-troubled codebase, or a written deliverable. Use when the user mentions "audit", "perf review", "perf sprint", "where do I start", or has no specific symptom yet.
Configuring R8 For Compose
Use this skill to configure R8 correctly for a Jetpack Compose application — full mode by default, `proguard-android-optimize.txt`, resource shrinking on, and minimal keep rules because Compose ships consumer ProGuard rules. Covers AGP 8.0+ R8 full mode default, R8's Compose-aware optimizations (lambda grouping, `sourceInformation()` stripping, composable arg constant-folding, `ComposerImpl` devirtualization), legitimate keep needs (`@Serializable`, Hilt entry points, reflective `Saver`s), and the AGP 8.x missing-rule reporter / R8 retrace. Cited gain is roughly 75 percent startup and 60 percent frame-render improvement debug-to-release. Use when setting up a new Compose app, when a PR adds an over-broad keep like `-keep class androidx.compose.** { *; }`, when a release build crashes after enabling minification, when APK size needs reduction, or when first enabling minification.
Stabilizing Compose Types
Use this skill to fix unstable Jetpack Compose types once a stability diagnosis has identified them. Covers the three-tier strategy — make the type truly stable with val plus immutable fields, mark with @Immutable or @Stable when the source is owned, and use stabilityConfigurationFiles for third-party or Java types. Explains the compiler-level difference between @Immutable and @Stable (static expression promotion), kotlinx.collections.immutable for List/Set/Map parameters, and the StableHolder wrapper escape hatch. Use when the developer asks how to stabilize a User class, a List parameter, java.time.LocalDateTime, a Flow parameter, or when the compiler report shows unstable params and the developer wants the fix. The diagnostic step lives in a sibling skill.