name: compose-stability description: Diagnose and fix Compose stability — @Stable, @Immutable, ImmutableList, strong skipping mode, compiler metrics, and lambda stability for zero-waste recomposition argument-hint: "" user-invocable: true
Compose skips recomposition of a composable when all its inputs are stable and equal to their previous values. An unstable parameter forces full recomposition of that subtree every time the parent recomposes.
A type is stable if:
equals() is always consistent for the same dataval) or observable via Compose snapshot stateStable by default:
Boolean, Int, Long, Float, Double, CharString() -> Unit, (T) -> RState<T> and MutableState<T>@Stable or @ImmutableUnstable by default:
List<T>, Map<K,V>, Set<T> — mutable implementations possible at runtimevar propertiesThe Compose compiler infers stability automatically for classes it can inspect. You only need explicit annotations when:
var properties backed by snapshot stateIf a data class with only stable val fields is in the same Compose module, the compiler will mark it stable without any annotation.
Use when all properties are val and all property types are themselves immutable:
@Immutable
data class ThemeColors(
val primary: Color,
val secondary: Color,
val background: Color,
)
@Immutable
data class UserProfile(
val id: String,
val name: String,
val avatarUrl: String,
)
Use when the type is in a non-Compose module, or holds mutable snapshot state:
// Needed: class lives in a :domain module without Compose compiler
@Stable
data class WordListState(
val words: ImmutableList<Word>,
val isLoading: Boolean,
val error: String?,
)
// Needed: holds MutableState
@Stable
class ThemeManager(
val current: MutableState<Theme> = mutableStateOf(Theme.Light)
)
Warning: @Stable and @Immutable are promises to the Compose compiler. Breaking them causes silent bugs — recompositions skip when they should run.
The most common stability issue — List<T> is considered unstable.
# gradle/libs.versions.toml
kotlinx-collections-immutable = "0.3.8"
// build.gradle.kts
implementation(libs.kotlinx.collections.immutable)
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
// Before — unstable, forces recomposition on every parent recompose
data class WordState(
val words: List<Word>, // UNSTABLE
val tags: Map<String, Int>, // UNSTABLE
)
// After — compiler infers stable (no annotation needed if in a Compose module)
data class WordState(
val words: ImmutableList<Word> = persistentListOf(), // STABLE
val tags: ImmutableMap<String, Int> = persistentMapOf(), // STABLE
)
// ViewModel — convert before updating state
updateState { copy(words = newWords.toImmutableList()) }
Lambdas that capture changing variables are unstable. Use function references or remember:
// Problem — new lambda created on every recomposition when word changes
WordCard(
word = word,
onClick = { viewModel.select(word) }, // captures word — new lambda each recompose
)
// Solution 1 — function reference (stable when viewModel is stable)
WordCard(
word = word,
onClick = viewModel::select, // works if select(word: Word)
)
// Solution 2 — remember with key
val onClick = remember(word.id) { { viewModel.select(word) } }
WordCard(word = word, onClick = onClick)
// Solution 3 — pass ID instead of full object
WordCard(
wordId = word.id,
onClick = viewModel::selectById, // stable function reference
)
Interfaces have no concrete type the compiler can inspect, so any composable that takes an interface parameter cannot be skipped.
// Problem — interface param forces recomposition even if impl is @Stable
interface WordClickHandler { fun onClick(word: Word) }
@Composable
fun WordCard(handler: WordClickHandler) { … } // UNSTABLE: interface
// Fix 1 — replace interface param with lambda
@Composable
fun WordCard(onClick: (Word) -> Unit) { … } // lambdas are stable
// Fix 2 — use a @Stable concrete class
@Stable
class WordClickHandlerImpl(private val vm: WordViewModel) : WordClickHandler {
override fun onClick(word: Word) = vm.select(word)
}
// Fix 3 — enable strong skipping (reference equality check handles it)
Types defined in modules without the Compose compiler plugin (e.g., a :domain module) are opaque — the compiler cannot verify their properties.
:domain ← pure Kotlin module, no Compose plugin → types appear unstable
:feature:words ← Compose module, uses domain types
Three options:
// Option A — annotate at definition site if you own the module
// In :domain module, add @Immutable/@Stable to expose the contract
@Immutable
data class Word(val id: String, val text: String)
// Option B — map to a UI model in the presentation layer (preferred for clean architecture)
@Immutable
data class WordUiModel(val id: String, val text: String) // stable, Compose-owned type
// Option C — stability-config.conf for types you don't own (see section below)
Enables skipping for composables with unstable parameters when all parameter instances are reference-equal. Also automatically remembers lambdas.
// Kotlin < 2.0 — opt in explicitly
composeCompiler {
enableStrongSkippingMode = true
}
// Kotlin 2.0+ — strong skipping is ON by default; no flag needed
// (Compose Compiler 1.5.8+ bundled with Kotlin 2.0)
With strong skipping:
@NonRestartableComposable for Leaf NodesPure leaf composables that read no state and have no child composables can skip the restart machinery entirely:
@NonRestartableComposable
@Composable
fun WordChip(text: String, modifier: Modifier = Modifier) {
// No state reads — parent recompose will re-invoke directly; no independent restart needed
Text(text = text, modifier = modifier)
}
Use only for true leaves — nodes that don't call other restartable composables.
Enable to find stability issues without guessing:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
./gradlew :composeApp:assembleDebug
# Reports in: build/compose_compiler/
What to look for in *-composables.txt:
restartable skippable fun WordCard( ← ideal: skippable
stable word: Word
stable onClick: Function0<Unit>
)
restartable fun WordListScreen( ← cannot skip — investigate params
unstable state: WordListState ← this is the culprit
)
What to look for in *-classes.txt:
stable WordListState ← good
unstable WordListState ← fix the fields, not just the annotation
unstable words: List<Word> ← replace with ImmutableList
For classes you don't own (e.g., from third-party libraries):
// stability-config.conf (in module root)
// Tell Compose compiler these types are stable
com.google.firebase.auth.FirebaseUser
java.time.LocalDate
java.util.UUID
// build.gradle.kts
composeCompiler {
stabilityConfigurationFiles.add(
rootProject.layout.projectDirectory.file("stability-config.conf")
)
}
build.gradle.kts./gradlew :composeApp:assembleDebug*-composables.txt for restartable without skippable — investigate each*-classes.txt for unstable — fix the fields, not just the annotationList<T> → ImmutableList<T> in all state/UI data classesMap<K,V> → ImmutableMap<K,V> in all state/UI data classes@Immutable UI models in the presentation layer@Stable concrete classes@Stable/@Immutable (or use stability-config.conf for third-party)@NonRestartableComposable