Compose can skip recomposing a function entirely if it can prove none of its inputs actually changed. Whether it can prove that comes down to a concept called stability. Understanding it explains a huge share of “why is this recomposing so much” problems.
What Makes a Type Stable
The Compose compiler considers a type stable if it can guarantee that whenever its public properties are equal (by equals()), the UI it produces is the same — and that it will notify Compose (via State) of any change. In practice:
- Primitive types (
Int,Boolean,String, etc.) are always stable. - A
data classwhere every property isvaland is itself a stable type is stable. MutableState<T>(frommutableStateOf) is stable — Compose knows it will be notified when the value inside changes.- A plain Kotlin
List,Map, orSetis not stable, because these interfaces don’t guarantee immutability — the underlying object could be a mutable list that changes without Compose being told. - A class with a
varproperty is not stable, for the same reason: it could change without notifying anyone.
Why It Matters
When every parameter of a composable is stable, Compose can skip calling it again if the parameter values are unchanged — even if its parent recomposed. When a parameter is unstable, Compose can’t make that guarantee, so it recomposes the function on every parent recomposition, whether anything meaningful changed or not.
// Unstable: List is an interface, not guaranteed immutable
@Composable
fun ProductList(products: List<Product>) { /* ... */ }
// Stable: ImmutableList from kotlinx.collections.immutable guarantees it
@Composable
fun ProductList(products: ImmutableList<Product>) { /* ... */ }
@Stable and @Immutable
When you have a class the compiler can’t infer stability for on its own — often because it lives in a module without the Compose compiler plugin, or contains a type Compose doesn’t recognize — you can assert it manually:
@Immutable— a promise that every property’s value will never change after construction. Use this for genuinely immutable data classes.@Stable— a slightly weaker promise: properties may change, but if they do, Compose will be notified (as with a class wrapping aMutableStateinternally) and equal values always produce equal UI.
@Immutable
data class UiState(
val isLoading: Boolean,
val products: ImmutableList<Product>
)
These annotations are a promise you’re making to the compiler, not something it verifies. Annotating a genuinely mutable class as @Immutable will make Compose skip recompositions it shouldn’t — producing a UI that silently fails to update, which is worse and harder to debug than the recomposition problem you were trying to fix.
Checking What the Compiler Actually Inferred
The Compose compiler can generate a stability report at build time (via the -P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=<path> compiler flag) listing every class it analyzed and whether it considered it stable. It’s the most reliable way to confirm your assumptions instead of guessing from recomposition counts alone — see our guide on debugging recomposition for the runtime side of this.