Compose skips recomposing a composable when its inputs compare equal to last time — but only if the compiler can prove those inputs are stable.
Get the types wrong and the skipping silently never happens.
#The old way
data class UiState(
val title: String,
val items: List<String>
)
@Composable
fun ItemList(state: UiState) {
Column {
state.items.forEach { Text(it) }
}
}
List is an interface. The compiler has no way to guarantee that whatever implementation shows up behind it is immutable, so UiState is inferred unstable.
Which means ItemList can never be skipped. It recomposes whenever its parent does, regardless of whether state actually changed.
#The new way
import androidx.compose.runtime.Immutable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class UiState(
val title: String,
val items: ImmutableList<String> = persistentListOf()
)
@Composable
fun ItemList(state: UiState) {
Column {
state.items.forEach { Text(it) }
}
}
#Why it matters
- A stable, skippable
ItemListrecomposes whenstateactually changes by structural equality — not just because its parent recomposed. @Immutableis a hard compiler promise: no property ever changes after construction.@Stableis the softer version, which allows mutation as long as reads are observable to Compose.kotlinx.collections.immutabletypes are stable by construction.List,MapandSetare not — the compiler always treats them as unstable.
#Gotcha
@Immutable isn't enforced at runtime. It's a promise, and you can break it by mutating a var inside the annotated class. Break it and the compiler will happily skip recomposition on data that genuinely changed, leaving stale UI on screen with no crash to point at. Verify what the compiler actually inferred with the Compose compiler's stability reports rather than trusting the annotation alone.