| name | stabilizing-compose-types |
| description | 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 wrapper escape hatch. Use when the developer asks how to stabilize a User class, a List parameter, java.time.LocalDateTime, a Flow parameter, or when the compiler report shows unstable params and the developer wants the fix. The diagnostic step lives in a sibling skill. |
| license | Apache-2.0. See LICENSE for complete terms. |
| metadata | {"author":"Jaewoong Eum (skydoves)","keywords":["jetpack-compose","performance","stability","immutable","stable-marker","kotlinx-collections-immutable","stability-configuration-file","strong-skipping"]} |
Stabilizing Compose Types — Three Tiers, In Order
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.
When to use this skill
- The compiler report (composables.txt / classes.txt) shows one or more
unstable parameters.
- The developer asks how to stabilize a domain class, a
List<Foo> parameter,
java.time.LocalDateTime, or a third-party type.
- The developer mentions
@Immutable, @Stable, compose-stable-marker,
compose-runtime-annotation, kotlinx.collections.immutable, stabilityConfigurationFiles, or
stability_config.conf.
- A
@TraceRecomposition log shows recomposition happening because an argument is allocated fresh
every recomposition.
When NOT to use this skill
- The unstable types are not yet identified — run
../diagnosing-compose-stability/SKILL.md first.
- The symptom is a wrong-phase state read (
Modifier.alpha(state.value)); use
../../recomposition/deferring-state-reads/SKILL.md.
- The symptom is a
derivedStateOf problem; use
../../recomposition/choosing-derivedstateof/SKILL.md.
- A
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.
Prerequisites
../diagnosing-compose-stability/SKILL.md has been run; the developer has a concrete list of
unstable types.
- Kotlin 2.0.0+ with
org.jetbrains.kotlin.plugin.compose applied. Strong Skipping is on by
default.
- For pure-Kotlin / data modules without
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.
- For tier-3 fixes: Compose Compiler 1.5.5+ for
stabilityConfigurationFiles DSL support (
plural; the older singular stabilityConfigurationFile property is deprecated, see footnote
below).
Workflow — decision tree
data class Snack(
var name: String,
val tags: Set<String>,
)
@Immutable
data class Snack(
val name: String,
val tags: ImmutableSet<String>,
)
dependencies {
compileOnly("androidx.compose.runtime:runtime-annotation:<version>")
}
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.
@Immutable
data class ThemeColors(
val primary: Color,
val onPrimary: Color,
val background: Color,
)
@Stable
class CartState {
var total: Money by mutableStateOf(Money.ZERO)
val lines: SnapshotStateList<Line> = mutableStateListOf()
}
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.
# 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) {
override fun equals(other: Any?) = other is StableHolder<*> && other.item == item
override fun hashCode() = item?.hashCode() ?: 0
}
@Composable
fun Feed(items: Flow<List<Item>>) { }
@Composable
fun FeedRoute(viewModel: FeedViewModel = viewModel()) {
val items by viewModel.items.collectAsStateWithLifecycle()
Feed(items = items)
}
@Composable
fun Feed(items: ImmutableList<Item>) { }
Patterns
Pattern: data class with var and Set field
data class Snack(
var name: String,
val tags: Set<String>,
)
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
@Immutable
data class Snack(
val name: String,
val tags: ImmutableSet<String> = persistentSetOf(),
)
Pattern: List<Item> parameter on a composable
@Composable
fun ItemList(items: List<Item>) { }
import kotlinx.collections.immutable.ImmutableList
@Composable
fun ItemList(items: ImmutableList<Item>) { }
Producer side:
val items: ImmutableList<Item> = repository.items().toImmutableList()
Pattern: java.time.LocalDateTime flagged unstable
The Java time types are separately compiled, no annotation, so the inference falls through to "
unknown". Tier 3 is the right answer.
# stability_config.conf
java.time.LocalDateTime
java.time.LocalDate
java.time.Instant
java.time.ZonedDateTime
java.time.Duration
@Stable
class DateWrapper(var date: LocalDateTime)
@Immutable
data class Order(val placedAt: LocalDateTime, val total: Money)
Pattern: Flow<T> parameter on a composable
@Composable
fun Detail(productFlow: Flow<Product>) {
val product by productFlow.collectAsState(initial = null)
}
@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.
Pattern: inline composable wrap as a "fix"
@Composable
fun ItemRow(item: Item) {
Row { }
}
@Immutable
data class Item(val id: Long, val name: String)
@Composable
fun ItemRow(item: Item) {
Row { }
}
Pattern: @Immutable versus @Stable — pick the stronger one
@Stable
data class ThemeColors(
val primary: Color,
val onPrimary: Color,
)
@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.
Mandatory rules
- MUST NOT annotate a type as
@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.
- MUST prefer
@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.
- MUST prefer
kotlinx.collections.immutable over annotating a mutable collection as stable.
The compiler ships a known-stable bitmask for these types.
- MUST NOT wrap inline composables (
Row/Column/Box) to "force" skippability. Inline
composables are not restartable in the first place; wrapping changes scoping without addressing
the root cause.
- MUST NOT pass
Flow<T> as a composable parameter. Collect upstream with
collectAsStateWithLifecycle.
- MUST re-run
../diagnosing-compose-stability/SKILL.md after applying fixes; the previously
unstable params MUST now report stable or runtime.
- PREFERRED:
stabilityConfigurationFiles (plural; stabilityConfigurationFiles.add(...)) over
scattering annotations across modules the team does not own.
- PREFERRED: in pure-Kotlin / data modules, use
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.
Verification
References