santimattius/performance-compose-skills/compose-composition-core

Claude Code and agent skill by santimattius/performance-compose-skills

Trust tier 2
  • community
LLM review pending
Source check pending

Checking that the SKILL.md file is still in the repository.

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.

Install

npx vskill@latest install santimattius/performance-compose-skills/compose-composition-core

SKILL.md

Performance First

Every Compose frame runs three phases. Cost rises left-to-right; restart scope shrinks left-to-right.

PhaseWhat RunsRestart CostTrigger
CompositionComposable functions, state readsHIGH (whole scope)State read in composable body
LayoutMeasure + placeMEDIUM (subtree)State read in measure lambda
DrawingCanvas commands, graphicsLayerLOW (single node)State read in draw/graphicsLayer lambda
Defer State Reads (decision tree)

Do you need the value during composition?

  • YES → read as T (Composition phase) — accept full restart cost
  • NO → can you defer to layout/draw?
  • Layout-only? → pass () -> T into Modifier.layout/offset { } lambda
  • Draw/transform? → pass () -> T into Modifier.graphicsLayer { } / drawBehind { } lambda

Rule: prefer Modifier.offset { lambda } over Modifier.offset(state.value.dp). Lambda variant defers the read to the layout phase, skipping recomposition entirely.

State Hoisting + Lambda-Deferred Read
kotlin
// 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))
}

Performance Toolchain

WARNING: NEVER profile in debug builds. Debug disables R8, inlining, and Strong Skipping — numbers are meaningless. Always profile a release build with profileable enabled or a benchmark build type.
ToolUse ForWhen
Baseline ProfilesAOT-compile critical paths (startup, scroll)Ship in release; regenerate per release
Compose Compiler ReportsDetect unstable params, restartable/skippable statusEvery PR; fail CI on new unstable types
Layout Inspector (recomposition counts)See which composables recompose and whyWhen debugging excess recomposition
Composition TracingFrame-level composition timing in Android StudioWhen Layout Inspector is not enough
MacrobenchmarkMeasure startup, frame timing, jank in releasePer-release regression gate

When to Use

Use this skill when:

  • A composable recomposes more than expected (excess invalidation, jank)
  • Deciding between remember, rememberSaveable, retain, or rememberSerializable
  • Using derivedStateOf and unsure if it is the right tool
  • Debugging unexpected state loss after configuration changes or process death
  • Working with CompositionLocal and unsure which variant to choose
  • Optimizing LazyColumn/LazyRow for reorder or insert/delete
  • Reviewing stability of custom classes and deciding when to apply @Stable/@Immutable

When to Use This vs compose-effects

compose-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.


CMP Applicability

Canonical CMP rules: `../_shared/cmp-platform.md`
Source setStatusNotes
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.


Critical Patterns

1. 3-Phase Lambda Deferral

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.

kotlin
// ✅ 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.

2. derivedStateOf — Only When Output Changes Less Than Input

derivedStateOf creates a snapshot state whose recomposition scope fires only when the RESULT changes, not every time an input changes.

kotlin
// ✅ 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.

kotlin
// ❌ 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 } }
3. Backwards-Write Warning
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.
kotlin
// ❌ 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") }
}
4. remember(key) for Expensive Computations

remember without keys is a permanent cache — it never updates if inputs change.

kotlin
// ❌ 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.

5. LazyList Key — Mandatory for Correct Identity

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.

kotlin
// ❌ 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.

6. 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.

7. State Lifespan Ladder
FunctionSurvives RecompositionSurvives Config ChangeSurvives Process DeathAccepts Non-Serializable
rememberYesNoNoYes
retainYesYesNoYes
rememberSaveableYesYesYes (Bundle types)No (needs Saver)
rememberSerializableYesYesYes (Java serialization)No
  • Use remember for transient UI state (expansion, focus, interaction source).
  • Use retain for heavy non-serializable objects that must survive rotation (e.g., ExoPlayer).
  • Use rememberSaveable for user-entered input and UI state that must survive system-initiated process death.
  • Use 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. rememberSerializable is not yet in stable Compose as of 2026-05; check BOM release notes.
8. Strong Skipping Mode

Default since Kotlin 2.0.20. Key behavior changes:

  • Unstable types are now compared by reference (===) instead of being treated as always-changed. This eliminates many false recompositions.
  • ALL lambdas in composable functions are automatically wrapped in remember { } with their captured variables as keys. Lambda identity is stable unless captured variables change.
  • To opt out of lambda memoization for a specific lambda, annotate with @DontMemoize.
kotlin
// 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.
9. CompositionLocal

CompositionLocal propagates values implicitly down the composition tree. Choose the variant based on how often the value changes.

VariantRecomposition on changeUse When
compositionLocalOfOnly direct readers recomposeValue changes at runtime (theme colors, locale)
staticCompositionLocalOfENTIRE subtree recomposesValue is effectively constant after first provision
kotlin
// ✅ 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 CompositionLocal as 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.

Pitfalls

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewNot runA model review is scheduled for the next rescan.

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
1
Repository forks
1
7-day trend score
8

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/santimattius/performance-compose-skills/compose-composition-core/badge)](https://verified-skill.com/skills/santimattius/performance-compose-skills/compose-composition-core)

Something wrong with this skill or its page? Tell us.