From performance-compose-skills
Jetpack Compose composition internals: state management, recomposition mechanics, stability, component identity, CompositionLocal, and the 3-phase performance model. Trigger: when working with Compose state, recomposition bugs, derivedStateOf, remember variants, stability annotations, LazyList performance, or CompositionLocal.
How this skill is triggered — by the user, by Claude, or both
Slash command
/performance-compose-skills:compose-composition-coreThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.
Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.
| Phase | What Runs | Restart Cost | Trigger |
|---|---|---|---|
| Composition | Composable functions, state reads | HIGH (whole scope) | State read in composable body |
| Layout | Measure + place | MEDIUM (subtree) | State read in measure lambda |
| Drawing | Canvas commands, graphicsLayer | LOW (single node) | State read in draw/graphicsLayer lambda |
Do you need the value during composition?
T (Composition phase) — accept full restart cost() -> T into Modifier.layout/offset { } lambda() -> T into Modifier.graphicsLayer { } / drawBehind { } lambdaRule: prefer Modifier.offset { lambda } over Modifier.offset(state.value.dp). Lambda variant defers the read to the layout phase, skipping recomposition entirely.
// Parent holds the state but does NOT read it in body
@Composable
fun ParentScreen() {
val scrollOffset by rememberInfiniteTransition().animateFloat(
initialValue = 0f, targetValue = 300f,
animationSpec = infiniteRepeatable(tween(1000))
)
// Pass lambda — child decides WHEN to read (layout phase, not composition)
OffsetBox(offsetProvider = { scrollOffset })
}
@Composable
fun OffsetBox(offsetProvider: () -> Float) {
Box(
Modifier
// ✅ Reads in layout phase — no recomposition of OffsetBox
.offset { IntOffset(0, offsetProvider().roundToInt()) }
.size(100.dp)
.background(Color.Blue)
)
}
// ❌ Anti-pattern: reads in Composition, forces OffsetBox to recompose every frame
@Composable
fun OffsetBoxBad(offset: Float) {
Box(Modifier.offset(0.dp, offset.dp).size(100.dp).background(Color.Blue))
}
WARNING: NEVER profile in debug builds. Debug disables R8, inlining, and Strong Skipping — numbers are meaningless. Always profile a release build with
profileableenabled or a benchmark build type.
| Tool | Use For | When |
|---|---|---|
| Baseline Profiles | AOT-compile critical paths (startup, scroll) | Ship in release; regenerate per release |
| Compose Compiler Reports | Detect unstable params, restartable/skippable status | Every PR; fail CI on new unstable types |
| Layout Inspector (recomposition counts) | See which composables recompose and why | When debugging excess recomposition |
| Composition Tracing | Frame-level composition timing in Android Studio | When Layout Inspector is not enough |
| Macrobenchmark | Measure startup, frame timing, jank in release | Per-release regression gate |
Use this skill when:
remember, rememberSaveable, retain, or rememberSerializablederivedStateOf and unsure if it is the right toolCompositionLocal and unsure which variant to chooseLazyColumn/LazyRow for reorder or insert/delete@Stable/@Immutablecompose-composition-core covers state lifecycle, recomposition mechanics, and stability decisions — everything that happens during the Composition phase and its identity model.
compose-effects covers LaunchedEffect, DisposableEffect, SideEffect, and snapshotFlow — operations triggered BY composition but that run outside it (coroutines, subscriptions, cleanup).
Use both when: reading a StateFlow inside a composable (collectAsStateWithLifecycle bridges them), or when derivedStateOf inside remember is combined with snapshotFlow.
Canonical CMP rules:
../_shared/cmp-platform.md
| Source set | Status | Notes |
|---|---|---|
commonMain | ✅ | All patterns fully supported |
androidMain | ✅ | Full skill content applies |
iosMain | ✅ | remember, derivedStateOf, CompositionLocal all commonMain-safe |
desktopMain | ✅ | Same as above |
wasmJsMain | ✅ | Same as above |
Status legend: ✅ fully supported · ⚠️ partial / version-gated · ❌ Android-only.
If using in CMP: Watch for CMP-REMEMBER-PLATFORM-LEAK — never capture android.content.Context, Activity, or UIViewController inside remember { } in commonMain. These are platform types and will cause compilation failures on non-Android targets. Use expect/actual to provide platform context where needed.
The fastest recomposition is the one that never happens. Pass () -> T when the parent holds state but the child is the only one that renders it.
// ✅ Scroll offset read deferred to Layout phase
Modifier.offset { IntOffset(0, scrollState.value) }
// ❌ Read in Composition — recomposes the whole scope on every scroll tick
Modifier.offset(scrollState.value.dp)
Only use provider lambdas (() -> T) when you have a measured performance problem. They add lambda allocation overhead and reduce readability. Always measure before and after.
derivedStateOf — Only When Output Changes Less Than InputderivedStateOf creates a snapshot state whose recomposition scope fires only when the RESULT changes, not every time an input changes.
// ✅ Correct: isAtTop changes rarely; firstVisibleItemIndex changes on every scroll tick
val isAtTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
// ❌ Wrong: output equals input — no filtering benefit, just extra overhead
val name by remember { derivedStateOf { user.name } }
Key rule: component parameters are NOT snapshot state — they cannot be observed by derivedStateOf. When derivedStateOf depends on a parameter, that parameter MUST be a remember key.
// ❌ Bug: threshold changes are silently ignored
val enabled by remember { derivedStateOf { password.length > threshold } }
// ✅ Fix: threshold is a remember key
val enabled by remember(threshold) { derivedStateOf { password.length > threshold } }
CRITICAL: Writing MutableState inside a composable body triggers another recomposition immediately, creating an infinite loop. This is one of the most dangerous patterns in Compose.
// ❌ Infinite recomposition loop — DO NOT do this
@Composable
fun BadComposable() {
var count by remember { mutableStateOf(0) }
Text("Count: $count")
count++ // backwards write — composition → write → recomposition → write → ...
}
// ✅ Writes belong in event handlers or effects
@Composable
fun GoodComposable() {
var count by remember { mutableStateOf(0) }
Text("Count: $count")
Button(onClick = { count++ }) { Text("Increment") }
}
remember(key) for Expensive Computationsremember without keys is a permanent cache — it never updates if inputs change.
// ❌ Stale cache — if users changes, sortedUsers never updates
val sortedUsers = remember { users.sortedBy { it.name } }
// ✅ Keys are exhaustive — cache invalidates when any input changes
val sortedUsers = remember(users, comparator) { users.sortedWith(comparator) }
Do NOT add snapshot state reads as keys. Snapshot state is observed automatically inside the remember lambda; adding it as a key triggers full reinitialization instead of the cheaper read-update cycle.
Without a key, LazyColumn uses index-based identity. Inserting or removing an item shifts all indices below it, causing full recomposition of every shifted item.
// ❌ Index identity — insert at top recomposes ALL items
LazyColumn {
items(items) { item -> ItemRow(item) }
}
// ✅ Stable key identity — only the changed item recomposes
LazyColumn {
items(items, key = { it.id }) { item -> ItemRow(item) }
}
Key constraints: must be unique among siblings; on Android, must be a Bundle-storable type (String, Int, etc.); must remain stable for the same logical item.
Modifier.offset { lambda } vs Modifier.offset(value)See Pattern 1 and the Code Examples section for the full contrast. The short rule: any rapidly-changing state (animations, scroll offsets) that affects only position/transform belongs in a lambda overload to confine the read to the Layout or Drawing phase.
| Function | Survives Recomposition | Survives Config Change | Survives Process Death | Accepts Non-Serializable |
|---|---|---|---|---|
remember | Yes | No | No | Yes |
retain | Yes | Yes | No | Yes |
rememberSaveable | Yes | Yes | Yes (Bundle types) | No (needs Saver) |
rememberSerializable | Yes | Yes | Yes (Java serialization) | No |
remember for transient UI state (expansion, focus, interaction source).retain for heavy non-serializable objects that must survive rotation (e.g., ExoPlayer).rememberSaveable for user-entered input and UI state that must survive system-initiated process death.rememberSerializable only when you have no other option — Java serialization is fragile.Stability note for
retain: available in Compose runtime snapshots; verify your BOM version before use.rememberSerializableis not yet in stable Compose as of 2026-05; check BOM release notes.
Default since Kotlin 2.0.20. Key behavior changes:
===) instead of being treated as always-changed. This eliminates many false recompositions.remember { } with their captured variables as keys. Lambda identity is stable unless captured variables change.@DontMemoize.// Under Strong Skipping Mode, this lambda is automatically memoized:
@Composable
fun Screen(viewModel: ScreenViewModel) {
ItemList(onClick = { viewModel.onItemClick(it) })
// Equivalent to: onClick = remember(viewModel) { { viewModel.onItemClick(it) } }
}
Profiling note: debug builds disable Strong Skipping. ALWAYS profile release builds.
CompositionLocal propagates values implicitly down the composition tree. Choose the variant based on how often the value changes.
| Variant | Recomposition on change | Use When |
|---|---|---|
compositionLocalOf | Only direct readers recompose | Value changes at runtime (theme colors, locale) |
staticCompositionLocalOf | ENTIRE subtree recomposes | Value is effectively constant after first provision |
// ✅ For frequently changing values: only readers recompose
val LocalThemeMode = compositionLocalOf { ThemeMode.Light }
// ✅ For static values: cheaper read, but ANY change recomposes the whole subtree
val LocalAppConfig = staticCompositionLocalOf<AppConfig> { error("No AppConfig provided") }
// Usage (same for both)
CompositionLocalProvider(LocalThemeMode provides ThemeMode.Dark) {
// All children can read LocalThemeMode.current
ChildComposable()
}
Do NOT use
CompositionLocalas a substitute for parameter passing in non-cross-cutting concerns. Reserve it for framework-level values (theme, locale, window info) that would otherwise require threading through many composable layers.
| Pitfall | Fix | Phase Cost |
|---|---|---|
| Reading rapidly-changing state in composable body | Pass () -> T lambda; child reads in layout/draw phase | Composition → Layout (full scope eliminated) |
derivedStateOf wrapping every state | Only use when output changes less than inputs | CPU overhead with no recomposition benefit |
val x = remember { expensiveCalc(input) } without key | remember(input) { expensiveCalc(input) } | Stale cache, wrong data shown |
| Writing MutableState in composable body | Move writes to event handlers, LaunchedEffect, SideEffect | Infinite recomposition loop |
items(list) without key in LazyColumn | items(list, key = { it.id }) | Full Composition on reorder |
Modifier.offset(scrollState.value.dp) | Modifier.offset { IntOffset(0, scrollState.value) } | Composition eliminated; Layout only |
staticCompositionLocalOf for frequently-changing values | compositionLocalOf — only readers recompose | Whole subtree Composition vs. just readers |
// ❌ Entire uiState passed down — any field change recomposes all children
@Composable
fun ChatScreen(vm: ChatViewModel) {
val uiState by vm.uiState.collectAsStateWithLifecycle()
ChatTopBar(uiState)
ChatMessages(uiState)
ChatBottomBar(uiState)
}
// ✅ Each child receives only its slice — unrelated changes skip recomposition
@Composable
fun ChatScreen(vm: ChatViewModel) {
val uiState by vm.uiState.collectAsStateWithLifecycle()
ChatTopBar(loading = uiState.loading)
ChatMessages(messages = uiState.messages)
ChatBottomBar(
text = uiState.messageInput,
onTextChange = vm::onInputChanged,
onSend = vm::onSend,
)
}
derivedStateOf with Scroll State@Composable
fun ContactsScreen(contacts: PersistentList<String>) {
val gridState = rememberLazyGridState()
// isAtTop only changes when crossing the boundary — not on every scroll tick
val isAtTop by remember { derivedStateOf { gridState.firstVisibleItemIndex == 0 } }
Scaffold(
floatingActionButton = {
if (!isAtTop) ScrollToTopFab(gridState)
}
) { padding ->
ContactsGrid(gridState, contacts, Modifier.padding(padding))
}
}
derivedStateOf@Stable
class MapState {
private val _location = mutableStateOf<Position?>(null)
// Each derived property has its own recomposition scope
val latitude by derivedStateOf { _location.value?.latitude }
val longitude by derivedStateOf { _location.value?.longitude }
fun move(position: Position) { _location.value = position }
}
retain for Heavy Non-Serializable Objects@Composable
fun VideoPlayer() {
val context = LocalContext.current.applicationContext
val player = retain {
ExoPlayer.Builder(context).build()
}
// player survives rotation; is released when composition leaves permanently
}
LazyColumn {
items(
items = todos,
key = { it.id }, // stable, Bundle-compatible
) { todo ->
TodoRow(todo)
}
}
# Generate compiler stability report
./gradlew assembleRelease # then check build/compose_compiler/
# Enable in build.gradle.kts:
# composeCompiler {
# reportsDestination = layout.buildDirectory.dir("compose_compiler")
# metricsDestination = layout.buildDirectory.dir("compose_compiler")
# }
# Check for new unstable types introduced in a PR (CI gate)
grep "unstable" build/compose_compiler/*-classes.txt
| Skill | Path | What It Adds |
|---|---|---|
compose-modifier-system | ../compose-modifier-system/SKILL.md | How modifiers interact with layout/drawing phases |
compose-effects | ../compose-effects/SKILL.md | Coroutine effects that read/write composition state |
| File | Covers |
|---|---|
| references/README.md | Index of all reference files |
| references/snapshot-state-and-recomposition.md | Snapshot state, remember ladder, recomposition propagation |
| references/derived-state.md | derivedStateOf rules and keys trap |
| references/stability-and-skipping.md | Stability categories, Strong Skipping Mode |
| references/component-identity.md | Identity, key, LazyList keys, movableContentOf |
remember, rememberSaveable, derivedStateOf, CompositionLocal, key()retain — available in Compose runtime snapshots; verify BOM version before use. rememberSerializable — not yet in stable Compose as of 2026-05; check BOM release notes.Provides a checklist for code reviews covering functionality, security, performance, maintainability, tests, and quality. Use for pull requests, audits, team standards, and developer training.
npx claudepluginhub santimattius/performance-compose-skills --plugin performance-compose-skills