How Recomposition Actually Works
Does stability still matter with strong skipping?
List for example, made a composable unskippable. This article used to say so. That stopped being the default with Kotlin 2.0.20.
Strong skipping is "enabled by default in Kotlin 2.0.20", and it changes the rule: "With strong skipping enabled, all restartable composable functions become skippable. This applies whether or not they have unstable parameters."
What stability decides now is how a parameter is compared:
| Parameter | Compared with | Skipped when |
|---|---|---|
| Stable | Object equality, equals() | The new value equals the old one |
| Unstable | Instance equality, === | The very same object is passed again |
List<User> parameter is no longer a problem by itself. It becomes one when the caller builds a new list on every recomposition, because a new instance never passes ===. The documentation names the common source: "data sources such as Room will allocate new objects for every item in the list any time one of them changes". That is the case where @Stable, @Immutable or an immutable collection still earns its place, because it switches the comparison back to equality.
Why List is unstable at all: "the Compose compiler cannot be completely sure that collections such as List, Map, and Set are truly immutable and therefore marks them as unstable" (Fix stability issues).
Do not chase a fully skippable codebase. Google's own warning: "Trying to make your entire UI skippable is a premature optimization that could lead to maintenance difficulties in the future" (Diagnose stability issues).// With strong skipping (default from Kotlin 2.0.20) this is skippable.
// It is skipped when the SAME list instance is passed again.
@Composable
fun UserList(users: List<User>) {
LazyColumn {
items(users, key = { it.id }) { user -> UserRow(user) }
}
}
// PROBLEM: a new list instance on every recomposition never
// passes ===, so UserList recomposes every time.
@Composable
fun UsersScreen(all: List<User>, query: String) {
UserList(users = all.filter { query in it.name })
}
// FIX 1: produce the list once per input change
@Composable
fun UsersScreen(all: List<User>, query: String) {
val visible = remember(all, query) {
all.filter { query in it.name }
}
UserList(users = visible)
}
// FIX 2: an immutable collection is stable, so it is compared
// with equals() and equal content skips.
import kotlinx.collections.immutable.ImmutableList
@Composable
fun UserList(users: ImmutableList<User>) { /* ... */ }derivedStateOf: Computed State Without Recomposition Storms
derivedStateOf is for one situation: an input that changes far more often than the result you compute from it. A list's first visible index changes on every item scrolled past. Whether to show a scroll-to-top button changes twice. In the documentation's words, derivedStateOf "creates a new Compose state object you can observe that only updates as much as you need" (Side-effects).
It is not a general wrapper for computed values. The same page carries the caution: "derivedStateOf is expensive, and you should only use it to avoid unnecessary recomposition when a result hasn't changed." It also describes the common misuse, combining two state values that change exactly as often as the result does, as "purely overhead and not required". If input and output change at the same rate, a plain expression or remember with keys is the right tool.@Composable
fun MessageList(messages: ImmutableList<Message>) {
val listState = rememberLazyListState()
// BAD: read in composition, so this composable recomposes
// every time the first visible index changes
// val showButton = listState.firstVisibleItemIndex > 0
// GOOD: recomposes only when the boolean flips
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
Box {
LazyColumn(state = listState) {
items(messages, key = { it.id }) { message ->
MessageBubble(message)
}
}
AnimatedVisibility(
visible = showScrollToTop,
modifier = Modifier.align(Alignment.BottomEnd)
) {
ScrollToTopButton()
}
}
}
// NOT a use for derivedStateOf: the result changes exactly
// as often as its inputs.
// val fullName by remember { derivedStateOf { "$first $last" } }
val fullName = "$first $last"Lambdas: What the Compiler Now Does for You
remember so that child composables would see a stable reference. With strong skipping that is the compiler's job: "With strong skipping enabled, every lambda inside a composable function will be automatically remembered." The generated remember is keyed on what the lambda captures.
The section was also wrong about the cause. It called a capturing lambda "unstable". The documentation corrects exactly that: "A common misconception is that lambdas with unstable captures are themselves unstable objects. This is not true. The Compose compiler always considers lambdas to be stable." The old cost came from allocation: a fresh lambda object each recomposition, unequal to the last one.
What is left for you is the technique that never depended on the compiler, deferring the state read. "Deferring state reads will ensure that Compose re-runs the minimum possible code on recomposition" (best practices). Pass a lambda that returns the value, or use the lambda form of a modifier, and the read happens in layout or draw. Composition is skipped entirely. That is the same mechanism that keeps animations off the recomposition path.// What you write
@Composable
fun Parent(item: Item, onItemClick: (String) -> Unit) {
ItemRow(item = item, onClick = { onItemClick(item.id) })
}
// What the compiler generates with strong skipping:
// the lambda is remembered, keyed on its captures.
@Composable
fun Parent(item: Item, onItemClick: (String) -> Unit) {
val onClick = remember(item, onItemClick) {
{ onItemClick(item.id) }
}
ItemRow(item = item, onClick = onClick)
}
// Deferring a read: scroll changes every frame
// BAD: the value is read in composition
Title(snack, scroll.value)
// GOOD: the lambda is read where the value is used
Title(snack) { scroll.value }
// Same idea with a modifier: the lambda form reads in the
// layout phase, so composition does not re-run.
Box(Modifier.offset { IntOffset(0, scroll.value) })Lazy Layout Performance
LazyColumn inside a vertically scrolling Column would compose every item at once. It does not get that far. The documentation's sample is labelled with the outcome: it throws IllegalStateException. The supported shape is one LazyColumn whose DSL emits the header and the items together.// BAD: no keys, identity is position
LazyColumn {
items(users) { user -> UserRow(user) }
}
// GOOD: a stable, unique key per item
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user)
}
}
// BAD: throws IllegalStateException
Column(Modifier.verticalScroll(rememberScrollState())) {
Text("Header")
LazyColumn {
items(users, key = { it.id }) { UserRow(it) }
}
}
// GOOD: one lazy list, header as an item
LazyColumn {
item {
Text(
"Team Members",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp)
)
}
items(users, key = { it.id }) { user ->
UserRow(user)
}
}Profiling with Layout Inspector and Compiler Reports
android { }. The earlier sample here nested it inside android { } and suggested a -PcomposeCompilerReports flag that does nothing unless your own build script reads it. Both are fixed below. One instruction from the guide matters more than the rest: "Make sure to always run this on a release build to ensure accurate results."
Read the report for the cases the stability section described: a class you expected to be stable that is not, feeding a composable that receives new instances. Fix those. Leave the rest alone.// module build.gradle.kts
android {
// ...
}
// Top level, not inside android { }
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}
// Run: ./gradlew assembleRelease
// Read build/compose_compiler/<module>-composables.txt
//
// restartable skippable fun SnackCollection(
// stable snackCollection: SnackCollection
// stable onSnackClick: Function1<Long, Unit>
// )
//
// and <module>-classes.txt for why a class is unstable:
//
// unstable class Snack {
// stable val id: Long
// unstable val tags: Set<String>
// }Key Takeaways
- 1Strong skipping is on by default from Kotlin 2.0.20: every restartable composable is skippable, with or without unstable parameters.
- 2Stability now decides the comparison. Stable parameters use equals(), unstable ones use ===, so new instances are what defeat skipping.
- 3Lambdas inside composables are remembered by the compiler. Hand-written remember around callbacks is no longer needed.
- 4derivedStateOf is expensive. Use it only when an input changes much more often than the result.
- 5Give lazy list items stable keys, and never nest a LazyColumn in a vertically scrolling Column: it throws.
- 6Measure a release build. Use Layout Inspector counts first, then the compiler report to explain them.
Frequently Asked
Why is my Compose UI janky?
The usual causes are work done in a composable body on every frame, state read higher in the tree than it is used, lazy lists without keys, and new object instances passed as parameters on each recomposition. Confirm it on a release build first, because debug builds carry a performance cost of their own.
When should I use remember versus derivedStateOf?
Use remember, with keys, to keep the result of a calculation across recompositions. Use derivedStateOf only when an input changes far more often than the value you derive from it, such as scroll position feeding a boolean. The documentation calls derivedStateOf expensive and warns against using it merely to combine two states.
Do I still need @Stable and immutable collections with strong skipping?
Sometimes. Strong skipping makes composables with unstable parameters skippable, but it compares those parameters by instance. If your data layer allocates new objects whenever anything changes, instance comparison fails every time. Marking the class @Stable or passing an immutable collection switches the comparison to equals(), so equal content is skipped.
Ready to architect your next Android app?
ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.