From chrisbanes-skills
Optimizes Jetpack Compose state reads to prevent unnecessary recomposition from frame-rate state (scroll, animation, gesture) and back-writing across phases.
How this skill is triggered — by the user, by Claude, or both
Slash command
/chrisbanes-skills:compose-state-deferred-readsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
State reads invalidate the phase that reads them. If a `State<T>` is read in a composable body, changes invalidate composition. If it is read in layout or draw, changes can invalidate only layout or draw. Frame-rate state such as scroll offsets, animations, and drag positions usually belongs in layout/draw, not composition.
State reads invalidate the phase that reads them. If a State<T> is read in a composable body, changes invalidate composition. If it is read in layout or draw, changes can invalidate only layout or draw. Frame-rate state such as scroll offsets, animations, and drag positions usually belongs in layout/draw, not composition.
Back-writing is the symmetric failure mode: writing observable state from a phase that triggers invalidation of an earlier phase. Compose phases run composition → layout → draw. Writing snapshot-backed state from layout or draw to state read in composition invalidates composition; writing during composition to state read earlier in the same composition does the same. Both schedule extra work — often cascading into sibling lazy items.
The fix is structural: keep the State<T> or a provider lambda and read the value inside a layout/draw callback; capture measurements in callbacks and apply them in the measure phase, not by reading measurement state in sibling composable bodies.
val x by animate*AsState(...) is passed to Modifier.offset(x = ...), Modifier.size(...), Modifier.graphicsLayer(...), or another value-form modifier.LazyListState.firstVisibleItemScrollOffset, ScrollState.value, Animatable.value, or gesture state is read in a composable body.scrollOffset: Int, progress: Float, dragOffset: Offset, or similar frame-rate values.stateMap[key] = …, list.addAll(…), or similar on every recomposition (back-writing composition → composition).onSizeChanged / onGloballyPositioned and a sibling reads that height in composition (Modifier.height(state.dp)) — back-writing layout → composition.Back-writing = writing observable state in one phase that triggers invalidation of an earlier (or the current) phase. Compose runs composition → layout → draw, so:
Modifier.layout, onSizeChanged, onGloballyPositioned) that's read during composition.In all cases the writer schedules extra invalidation passes — often cascading into sibling lazy items.
Do not write to mutableStateOf, mutableStateListOf, mutableStateMapOf, or other snapshot-backed state from the composable body on every pass:
// ❌ BAD — mutates observable map during composition; siblings recompose repeatedly
@Composable
fun MergeOverlay(parent: Map<Key, ViewState>, overlay: Map<Key, ViewState>): Map<Key, ViewState> {
val merged = remember { mutableStateMapOf<Key, ViewState>() }
merged.clear()
merged.putAll(parent)
merged.putAll(overlay) // back-writing composition → composition
return merged
}
// ✅ GOOD — read-only merge; no composition-time writes
@Composable
fun MergeOverlay(parent: Map<Key, ViewState>, overlay: Map<Key, ViewState>): Map<Key, ViewState> =
remember(parent, overlay) {
if (overlay.isEmpty()) parent else parent + overlay
}
Prefer remember(keys) { … } for derived read-only snapshots. Reserve mutableState* writes for event callbacks (onClick) or effects — not for rebuilding derived data on every composition.
Callbacks like onSizeChanged write during layout. That is only safe if no earlier phase reads the resulting state — see cross-row measurement below.
When row A measures and row B must match A's height, do not read A's captured size in B's composable body. onSizeChanged writes during layout; if B reads it in composition, layout has just back-written into composition:
var anchorHeightPx by remember { mutableIntStateOf(0) }
// ❌ BAD — B reads measurement state in composition; insertion/focus can double-recompose B
RowA(Modifier.onSizeChanged { anchorHeightPx = it.height })
RowB(Modifier.height(with(LocalDensity.current) { anchorHeightPx.toDp() })) // composition read
// ✅ GOOD — capture on A; apply on B in measure phase only
RowA(Modifier.onSizeChanged { if (it.height != anchorHeightPx) anchorHeightPx = it.height })
RowB(
Modifier.decorateMeasureConstraints { incoming ->
if (anchorHeightPx > 0) incoming.copy(minHeight = anchorHeightPx, maxHeight = anchorHeightPx)
else incoming
},
)
decorateMeasureConstraints is a small layout helper (see compose-modifier-and-layout-style). While height is unknown, siblings use a fixed fallback in composition; once known, only layout invalidates — not an extra composition cascade.
Several modifiers have value forms and block forms. The value form receives values already read in composition; the block form can read during layout or draw.
// Before: animated value read in composition by the `by` delegate
@Composable
fun SelectionPill(selectedIndex: Int) {
val offsetX by animateDpAsState(120.dp * selectedIndex)
Box(Modifier.offset(x = offsetX))
}
// After: State is kept, value is read in the layout-phase offset block
@Composable
fun SelectionPill(selectedIndex: Int) {
val offsetX = animateDpAsState(120.dp * selectedIndex)
Box(
Modifier.offset {
IntOffset(offsetX.value.roundToPx(), 0)
},
)
}
Common replacements:
| Composition read | Deferred read |
|---|---|
Modifier.offset(x = animatedX) | Modifier.offset { IntOffset(animatedX.value.roundToPx(), 0) } |
Modifier.graphicsLayer(translationY = y) | Modifier.graphicsLayer { translationY = yProvider() } |
val radius by animateFloatAsState(...); drawBehind { drawCircle(radius = radius) } | val radius = animateFloatAsState(...); drawBehind { drawCircle(radius = radius.value) } |
The drawBehind block is already draw-phase; the important part is that the State.value read also happens inside that block.
If the fast-changing value would cross a composable boundary, pass a provider lambda instead of a snapshot value:
// Before: HomeScreen reads scroll offset in composition and passes the value down
@Composable
fun HomeScreen() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item { HeroImage(scrollOffset = listState.firstVisibleItemScrollOffset) }
}
}
@Composable
fun HeroImage(scrollOffset: Int, modifier: Modifier = Modifier) {
AsyncImage(
model = "...",
modifier = modifier.graphicsLayer(translationY = -scrollOffset / 2f),
)
}
// After: the only read happens inside graphicsLayer
@Composable
fun HomeScreen() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item {
HeroImage(
scrollOffsetProvider = {
if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset
} else {
0
}
},
)
}
}
}
@Composable
fun HeroImage(scrollOffsetProvider: () -> Int, modifier: Modifier = Modifier) {
AsyncImage(
model = "...",
modifier = modifier.graphicsLayer {
translationY = -scrollOffsetProvider() / 2f
},
)
}
Suffix provider parameters with Provider when that clarifies the deferred-read contract.
State reads can also be deferred inside:
Modifier.layout { measurable, constraints -> ... }Alignment.align(...)drawWithContent, drawBehind, and other draw modifiersgraphicsLayer { ... } and offset { ... }Use these when the state changes where something is placed or painted. If the state decides which composables exist, it belongs in composition.
| Symptom | Diagnosis | Fix |
|---|---|---|
val x by animateFloatAsState(...) then Modifier.offset(...) | by reads in composition | Keep State<Float> and read .value in offset {} |
Modifier.graphicsLayer(translationY = animatedY) | Property-argument form uses composition values | Use graphicsLayer { translationY = ... } |
Child(scrollOffset = listState.firstVisibleItemScrollOffset) | Fast-changing value crosses boundary | Child(scrollOffsetProvider = { ... }) |
| Draw block still recomposes every frame | Value was read before draw block | Move the State.value read inside the draw block |
| State chooses between different UI branches | Composition decision | Keep the read in composition |
mergedMap.putAll(overlay) in composable body | Back-writing composition → composition | remember(parent, overlay) { parent + overlay } |
Sibling Modifier.height(measuredPx.toDp()) | Back-writing layout → composition | Measure-phase constraint decoration |
| Identity cache for read-only merge | Stale overlay risk | remember(keys) on immutable result |
compose-state-authoring — when mutableState* belongs in composition vs callbacks.compose-state-holder-ui-split — where state-holder vs plain UI split applies when passing providers/lambdas across boundaries.compose-stability-diagnostics — parameter stability and compiler reports.compose-modifier-and-layout-style — measure-phase constraint decoration helper.npx claudepluginhub chrisbanes/skills --plugin chrisbanes-skillsRoutes Jetpack Compose recomposition investigations across three axes: parameter stability, State read phase, and cross-phase back-writing. Directs to focused skills for compiler reports, deferred reads, and side effects.
Guides building production-quality Android UIs with Jetpack Compose, including state management (ViewModel/StateFlow), type-safe navigation, and performance optimization.
Guides building native Android UIs with Jetpack Compose, including state management via remember/mutableStateOf, state hoisting, and ViewModel integration.