Help us improve
Share bugs, ideas, or general feedback.
From chrisbanes-skills
Guides correct local UI state management in Jetpack Compose: remember + mutableStateOf, mutableStateListOf/mutableStateMapOf, and @ReadOnlyComposable. Helps diagnose state that silently resets or optimizations that don't apply.
npx claudepluginhub chrisbanes/skills --plugin chrisbanes-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/chrisbanes-skills:compose-state-authoringThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Not every `remember { … }` belongs here. This skill covers **local UI state** (`remember { mutableStateOf(…) }`, `mutableStateListOf` / `mutableStateMapOf`) and **`@ReadOnlyComposable`**. Other remembered APIs live in focused skills:
Guides state hoisting decisions in Jetpack Compose: local remember, hoisted parameters, plain state holder, or ViewModel.
Guides building native Android UIs with Jetpack Compose, including state management via remember/mutableStateOf, state hoisting, and ViewModel integration.
Provides Jetpack Compose patterns for state hoisting, remember variants, slot APIs, modifiers, side effects, theming, animations, and performance in Android UI development.
Share bugs, ideas, or general feedback.
Not every remember { … } belongs here. This skill covers local UI state (remember { mutableStateOf(…) }, mutableStateListOf / mutableStateMapOf) and @ReadOnlyComposable. Other remembered APIs live in focused skills:
rememberCoroutineScope / rememberUpdatedState → compose-side-effectsrememberLazyListState / rememberScrollState used for frame-rate reads → compose-state-deferred-readsFocusRequester ownership, behavior → compose-focus-navigationA @Composable is a function the runtime re-runs whenever its inputs change. Writing local state correctly comes down to two questions:
var survive recomposition and trigger it? If not, it silently resets on every recompose and writes are invisible.remember) or only read it? If only read, @ReadOnlyComposable lets the runtime skip work.Get either wrong and the symptoms are subtle: state that vanishes or optimizations that don't apply.
You're writing or reviewing Compose code and you see any of these:
var x = … inside a @Composable fun or any composable lambda (Column { var x = … })@Composable fun (or @Composable get() property accessor) whose body never lays anything out@ReadOnlyComposable on a function that calls Text, Box, Column, remember, …var in a composable must be State-backedRecomposition re-executes the composable from the top. A local var is re-initialized on every pass — last recompose's value is gone, and writing to it doesn't tell the runtime to recompose.
// ❌ BAD — counter resets on every recomposition; clicks never update the UI
@Composable
fun Counter() {
var count = 0
Button(onClick = { count++ }) { Text("$count") }
}
// ❌ ALSO BAD — same rule applies inside composable content lambdas
@Composable
fun Wrapper() {
Row {
var count = 0 // Row's content lambda is @Composable too
// …
}
}
// ✅ GOOD — `remember` survives recomposition, `mutableStateOf` triggers it
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }
}
Two pieces and both matter:
remember { … } — survives recomposition. Without it the value is re-created each time.mutableStateOf(…) — triggers recomposition. Without it, mutations are invisible to the runtime.For collections, prefer mutableStateListOf / mutableStateMapOf (also remember-ed). They emit Snapshot reads on every read and Snapshot writes on every mutation. A remember { mutableStateOf(mutableListOf<X>()) } followed by list.add(x) will not recompose, because MutableList.add doesn't go through the State setter — you'd have to replace the value (state = state + x).
Back-writing means writing observable state in a phase that triggers invalidation of an earlier (or the current) phase. Mutating mutableState* from the composable body back-writes into the same composition pass and schedules another. Do not rebuild derived data this way:
// ❌ BAD — clear + putAll on every composition
val merged = remember { mutableStateMapOf<Key, ViewState>() }
merged.clear()
merged.putAll(parent)
merged.putAll(overlay)
// ✅ GOOD — immutable snapshot remembered from inputs
val merged = remember(parent, overlay) {
if (overlay.isEmpty()) parent else parent + overlay
}
If the result is read-only for the current inputs, remember(keys) { … } is enough. See compose-state-deferred-reads for cross-row measurement and measure-phase fixes.
remember { … }'s producer block. That runs once per key change, not on every recompose. A local var there is fine: val builder = remember { mutableListOf<X>().apply { var n = 0; … } }.@Composable lambdas passed out of a composable. onClick = { var a = 0; … } is a plain () -> Unit. Local vars there are normal Kotlin.@Composable) helper functions. Only composable scopes are affected.@ReadOnlyComposable contract@ReadOnlyComposable declares that a composable only reads composition state — no Text, no Box, no remember, no layout nodes, no positional slots. The runtime can then skip allocating a group for the call, which matters for fast accessor-style composables (MaterialTheme.colorScheme, LocalDensity.current, design-system token accessors).
The contract is bidirectional:
@ReadOnlyComposable when every composable call your body makes is itself @ReadOnlyComposable (or there are no composable calls at all — for example a function that only reads LocalFoo.current and returns a value).// ✅ GOOD — only reads composition locals, no layout, no remember
@Composable
@ReadOnlyComposable
fun appSpacing(): Dp = LocalDimensions.current.spacing
// ✅ GOOD — composable property getter; same rule
val accent: Color
@Composable @ReadOnlyComposable
get() = MaterialTheme.colorScheme.tertiary
// ❌ BAD — annotated read-only but lays out a Box; contract violated
@Composable
@ReadOnlyComposable
fun Header(): Int {
Box {} // ← non-read-only composable call
return 42
}
// ❌ BAD — calls a normal composable from a read-only one
@Composable
@ReadOnlyComposable
fun computed(): Int = nonReadOnlyHelper()
If the body contains any of these, do not add @ReadOnlyComposable:
Box, Column, Row, LazyColumn, Text, anything from androidx.compose.foundation.layout or androidx.compose.material*.LaunchedEffect, DisposableEffect, SideEffect, produceState.remember { … } — positional memoization is composition state.@Composable lambda invocation (content()).@ReadOnlyComposable composable function.If the body is only reading Local*.current, calling other @ReadOnlyComposable functions, or doing pure computation, add it.
override fun declarations. The annotation is part of the contract; if the base isn't @ReadOnlyComposable, you can't make an override one. Refactor the base, or accept the override pays the group-creation cost.If a composable needs LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, rememberUpdatedState, snapshotFlow, snackbar/navigation handling, analytics, or Flow collection, use compose-side-effects.
Focus splits by question: navigation, focus state, FocusRequester ownership, behavior → compose-focus-navigation; when to call imperative requestFocus (effect timing, lifecycle, keys, API choice) → compose-side-effects.
This skill is about authoring Compose state correctly. rememberUpdatedState is effect capture state, not a general replacement for remember { mutableStateOf(...) }. Side effects have separate lifecycle and keying rules, and keeping them in one focused skill avoids two sources of truth.
| Symptom | Diagnosis | Fix |
|---|---|---|
var x = … inside @Composable fun body | Not recomposition-safe (§1) | var x by remember { mutableStateOf(…) } |
var x = … inside Column { … } / Row { … } content lambda | Same — content lambdas are @Composable (§1) | Same fix |
remember { mutableStateOf(list) } then .add(x) not recomposing | Mutation bypasses State setter | Use mutableStateListOf, or replace the value: state = state + x |
stateMap.clear(); stateMap.putAll(...) in composable body | Back-writing composition → composition | remember(keys) { derivedSnapshot } |
@Composable fun with no Text/Box/remember/effect calls | Could be @ReadOnlyComposable (§2) | Add @ReadOnlyComposable above @Composable |
@ReadOnlyComposable function that calls Box {} / Column {} / a normal composable | Contract violation (§2) | Remove @ReadOnlyComposable |
composeTestRule.setContent { … } follow the same rules — they're production composables.produceState has its own producer block that runs in a coroutine; you don't need LaunchedEffect inside it.derivedStateOf has its own concerns around stability and equality — out of scope here; it's about preventing recomposition, not authoring state.overrides of read-only-composable declarations: the annotation is fixed by the base; you can't add or remove it locally.| Thought | Reality |
|---|---|
"It's a small composable, the bare var is fine" | Recomposition can fire at any time. The reset is non-deterministic by design — and a single bug report later. |
"I'll add @ReadOnlyComposable because the function looks simple" | "Simple" isn't the criterion. "Makes only read-only calls" is. |
"I always reach for LaunchedEffect because it's the one I know" | Use compose-side-effects; effect API choice depends on lifecycle and keys. |
"I'll just .add() to the remembered list" | A mutableStateOf(List) doesn't observe internal mutation — use mutableStateListOf or replace the value. |
"The override needs @ReadOnlyComposable to match what it does" | If the base isn't @ReadOnlyComposable, you can't add it to an override. Refactor the base instead. |