Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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 w
.claude/skills/skydoves-stabilizing-compose-types/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 106% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 207% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 181% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 325% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 105% | 0% |
Once a diagnosis (see ../diagnosing-compose-stability/SKILL.md) has named the unstable types, this skill walks Claude through fixing them. The strategy is a strict three-tier waterfall: (1) make the type truly stable by structural rewrite (val + immutable fields) — no annotation needed; (2) annotate with @Immutable or @Stable when the source is owned and the contract is honored; (3) use stabilityConfigurationFile for third-party or Java types. DO NOT invert this order — annotations are a contract, not a magic spell.
unstable parameters.List<Foo> parameter, java.time.LocalDateTime, or a third-party type.@Immutable, @Stable, compose-stable-marker, compose-runtime-annotation, kotlinx.collections.immutable, stabilityConfigurationFiles, or stability_config.conf.@TraceRecomposition log shows recomposition happening because an argument is allocated fresh every recomposition.../diagnosing-compose-stability/SKILL.md first.Modifier.alpha(state.value)); use ../../recomposition/deferring-state-reads/SKILL.md.derivedStateOf problem; use ../../recomposition/choosing-derivedstateof/SKILL.md.Flow<T> parameter is the cause; the fix is to collect upstream — see ../../side-effects/collecting-flows-safely/SKILL.md rather than annotating Flow as stable.../diagnosing-compose-stability/SKILL.md has been run; the developer has a concrete list of unstable types.org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by default.compose-runtime: either androidx.compose.runtime:runtime-annotation (official) or com.github.skydoves:compose-stable-marker (legacy) on the classpath. Both expose @Stable / @Immutable / @StableMarker without dragging in the full runtime.stabilityConfigurationFiles DSL support (plural; the older singular stabilityConfigurationFile property is deprecated, see footnote below).val of an already-stable type? If yes, MUST make that change first. No annotation is needed and no contract is implied. The compiler will infer stability automatically.kotlin// WRONG data class Snack( var name: String, val tags: Set<String>, ) // WRONG because: `var name` is an observable that does not notify Compose, and `Set<String>` is the standard library interface so its stability is Unknown — the data class is unstable on two axes.
kotlin// RIGHT @Immutable data class Snack( val name: String, val tags: ImmutableSet<String>, )
kotlin.collections.List, Set, Map with kotlinx.collections.immutable.ImmutableList, ImmutableSet, ImmutableMap. Build with persistentListOf(), persistentSetOf(), persistentMapOf() factories or .toImmutableList() adapters. The kotlinx-collections-immutable artifact ships a known-stable bitmask 0b1 recognized by the Compose Compiler. PREFERRED: prefer this over whitelisting kotlin.collections.* in stability_config.conf because the type system enforces immutability.compose-runtime? Use androidx.compose.runtime:runtime-annotation (official, recommended) or com.github.skydoves:compose-stable-marker (legacy). Both let the data module annotate types without pulling in the full Compose runtime. Skydoves hot take #6: pure-Kotlin / data modules can use compose-stable-marker (or newer official compose-runtime-annotation) without pulling in full compose-runtime.kotlin// shared data module — build.gradle.kts dependencies { // Official, preferred: compileOnly("androidx.compose.runtime:runtime-annotation:<version>") // Or legacy skydoves: // implementation("com.github.skydoves:compose-stable-marker:<version>") }
@Immutable or @Stable. If every property is val AND every nested value is itself immutable AND equals() is structural, use @Immutable. Otherwise — for types whose values can change but whose mutations are observed by Compose (e.g. holders containing MutableState) — use @Stable.The compiler treats @Immutable more aggressively than @Stable. For @Immutable types, when every constructor argument at a call site is a compile-time constant (e.g. literal numbers, top-level vals of stable types, or other @Immutable-with-constant-args), the compiler performs static expression promotion: the constructed instance is hoisted into a singleton, the lambda capture sites are de-duplicated, and the @Immutable parameter is marked @static in composables.txt. Most articles describe @Stable and @Immutable as interchangeable — they are not.
kotlin// RIGHT — @Immutable: every property val, every property type immutable @Immutable data class ThemeColors( val primary: Color, val onPrimary: Color, val background: Color, ) // RIGHT — @Stable: mutable but mutations notify Compose via Snapshot @Stable class CartState { var total: Money by mutableStateOf(Money.ZERO) val lines: SnapshotStateList<Line> = mutableStateListOf() }
stabilityConfigurationFiles (plural). Create stability_config.conf at the project root listing exact-class or wildcard patterns; wire it into the composeCompiler { } block via stabilityConfigurationFiles.add(...). Full grammar lives in references/stability-config-syntax.md.kotlin// build.gradle.kts (root or composable module) composeCompiler { stabilityConfigurationFiles.add( rootProject.layout.projectDirectory.file("stability_config.conf") ) }
> Footnote — legacy form. Older projects may still wire the file via the singular property stabilityConfigurationFile = .... That property is @Deprecated("Use the stabilityConfigurationFiles option instead") in modern Compose Compiler Gradle plugin releases — prefer the plural stabilityConfigurationFiles.add(...) shown above.
text# stability_config.conf — opt-in contract java.time.LocalDateTime java.time.LocalDate kotlin.collections.List kotlin.collections.Set kotlin.collections.Map com.example.thirdparty.**
@Stable class StableHolder<T>(val item: T). This is the escape hatch — the holder's identity is stable, equality delegates to item, and Compose can skip on it.kotlin@Stable class StableHolder<T>(val item: T) { override fun equals(other: Any?) = other is StableHolder<*> && other.item == item override fun hashCode() = item?.hashCode() ?: 0 }
Flow<T> parameters — DO NOT annotate as stable. Skydoves hot take #4: Flow parameters are unstable. Don't pass flows to composables; collect them in a ViewModel or with collectAsStateWithLifecycle.kotlin// WRONG @Composable fun Feed(items: Flow<List<Item>>) { /* ... */ } // WRONG because: Flow has no observable identity; the composable cannot skip on it, and a downstream `collectAsState` here detaches from lifecycle.
kotlin// RIGHT — collect upstream and pass the resolved value @Composable fun FeedRoute(viewModel: FeedViewModel = viewModel()) { val items by viewModel.items.collectAsStateWithLifecycle() Feed(items = items) } @Composable fun Feed(items: ImmutableList<Item>) { /* ... */ }
stable or runtime.var and Set fieldkotlin// WRONG data class Snack( var name: String, val tags: Set<String>, ) // WRONG because: `var` blocks compile-time stability inference; `Set<String>` is an interface with unknown implementations.
kotlin// RIGHT import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @Immutable data class Snack( val name: String, val tags: ImmutableSet<String> = persistentSetOf(), )
List<Item> parameter on a composablekotlin// WRONG @Composable fun ItemList(items: List<Item>) { /* ... */ } // WRONG because: `kotlin.collections.List` is an interface; the compiler cannot prove every implementation is immutable, so the parameter is `unstable` and skipping is disabled (or pinned to `===` under Strong Skipping, which still fails on every fresh allocation).
kotlin// RIGHT import kotlinx.collections.immutable.ImmutableList @Composable fun ItemList(items: ImmutableList<Item>) { /* ... */ }
Producer side:
kotlinval items: ImmutableList<Item> = repository.items().toImmutableList()
java.time.LocalDateTime flagged unstableThe Java time types are separately compiled, no annotation, so the inference falls through to "unknown". Tier 3 is the right answer.
text# stability_config.conf java.time.LocalDateTime java.time.LocalDate java.time.Instant java.time.ZonedDateTime java.time.Duration
kotlin// WRONG — annotate a wrapper to "force" stability without honoring the contract @Stable class DateWrapper(var date: LocalDateTime) // WRONG because: `var` field, no Snapshot notification — the @Stable contract is broken; recompositions will be silently missed.
kotlin// RIGHT — whitelist the immutable JDK type via stability config // stability_config.conf: java.time.LocalDateTime @Immutable data class Order(val placedAt: LocalDateTime, val total: Money)
Flow<T> parameter on a composablekotlin// WRONG @Composable fun Detail(productFlow: Flow<Product>) { val product by productFlow.collectAsState(initial = null) /* ... */ } // WRONG because: `Flow` is a cold producer with no observable identity; the composable can never skip on it, and `collectAsState` (no `WithLifecycle`) keeps collecting in the background.
kotlin// RIGHT — hoist collection to the route @Composable fun DetailRoute(viewModel: DetailViewModel = viewModel()) { val product by viewModel.product.collectAsStateWithLifecycle() Detail(product = product) } @Composable fun Detail(product: Product?) { /* ... */ }
Cross-reference: ../../side-effects/collecting-flows-safely/SKILL.md.
kotlin// WRONG — wrapping a Row in an extracted composable to "force" skippability @Composable fun ItemRow(item: Item) { Row { /* ... */ } } // WRONG because: `Row`/`Column`/`Box` are inline composables — they are NOT restartable/skippable to begin with (skydoves hot take #3). Wrapping them creates a new restart scope, which can change behavior unpredictably without addressing the root unstable parameter.
kotlin// RIGHT — make the parameter stable, leave the inline composable alone @Immutable data class Item(val id: Long, val name: String) @Composable fun ItemRow(item: Item) { Row { /* ... */ } }
@Immutable versus @Stable — pick the stronger onekotlin// SUFFICIENT but suboptimal @Stable data class ThemeColors( val primary: Color, val onPrimary: Color, ) // Sufficient because: the compiler still treats it as stable. // Suboptimal because: @Immutable would additionally enable static expression promotion when ThemeColors is constructed with all-constant args.
kotlin// PREFERRED @Immutable data class ThemeColors( val primary: Color, val onPrimary: Color, )
In composables.txt, calls like ThemeColors(Color(0xFFFFFFFF), Color(0xFF000000)) will then be reported as stable @static themeColors: ThemeColors — the constructed instance is hoisted to a singleton. MUST prefer @Immutable whenever the type qualifies.
@Stable or @Immutable unless the contract is honored. Skydoves hot take #2: stability config is a contract, not a magic spell — break it and recompositions are silently missed.@Immutable over @Stable whenever every property is a val of an already-immutable type. The compiler emits stronger optimizations (static expression promotion, lambda-singleton, compile-time default eval) for @Immutable.kotlinx.collections.immutable over annotating a mutable collection as stable. The compiler ships a known-stable bitmask for these types.Row/Column/Box) to "force" skippability. Inline composables are not restartable in the first place; wrapping changes scoping without addressing the root cause.Flow<T> as a composable parameter. Collect upstream with collectAsStateWithLifecycle.../diagnosing-compose-stability/SKILL.md after applying fixes; the previously unstable params MUST now report stable or runtime.stabilityConfigurationFiles (plural; stabilityConfigurationFiles.add(...)) over scattering annotations across modules the team does not own.androidx.compose.runtime:runtime-annotation (official) or com.github.skydoves:compose-stable-marker (legacy) so the annotation is available without a full compose-runtime dependency.composeCompiler { reportsDestination = ... } enabled.composables.txt, every previously unstable parameter is now stable or runtime.classes.txt, every fixed class is now stable class … or runtime stable class … — no unstable remains for the targeted types.stabilityConfigurationFiles was used, every entry has been verified to honor the contract (no hidden mutation, structural equals, no observable that bypasses Snapshot).@TraceRecomposition (skydoves/compose-stability-analyzer) on the previously hot composable shows recomposition counts dropping to the expected number per state change.@Stable / @Immutable annotation has been added to a type that still contains a var or an unstable nested type.references/stability-config-syntax.md — full grammar of stability_config.conf with worked examples.Other measured skills in the registry, with their headline benchmark lift.