How Recomposition Actually Works
Stability: The Root of Most Recomposition Problems
// UNSTABLE: List is a mutable interface
@Composable
fun UserList(users: List<User>) {
// Recomposes every time parent recomposes
LazyColumn {
items(users) { user -> UserRow(user) }
}
}
// STABLE: Use kotlinx.collections.immutable
import kotlinx.collections.immutable.ImmutableList
@Composable
fun UserList(users: ImmutableList<User>) {
// Skippable -- only recomposes when list changes
LazyColumn {
items(users) { user -> UserRow(user) }
}
}
// Alternative: wrap in a stable wrapper
@Immutable
data class UserListState(
val users: List<User>
)
@Composable
fun UserList(state: UserListState) {
// Skippable!
LazyColumn {
items(state.users) { user -> UserRow(user) }
}
}
// Check stability with the Compose compiler report:
// ./gradlew assembleRelease -PcomposeCompilerReports=truederivedStateOf: Computed State Without Recomposition Storms
@Composable
fun MessageList(messages: ImmutableList<Message>) {
val listState = rememberLazyListState()
// BAD: Recomposes on every scroll event
// val showButton = listState.firstVisibleItemIndex > 5
// GOOD: Only recomposes when boolean changes
val showScrollToTop by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 5
}
}
Box {
LazyColumn(state = listState) {
items(messages) { message ->
MessageBubble(message)
}
}
AnimatedVisibility(
visible = showScrollToTop,
modifier = Modifier.align(Alignment.BottomEnd)
) {
FloatingActionButton(onClick = { /* scroll */ }) {
Icon(Icons.Default.KeyboardArrowUp, null)
}
}
}
}Lambda Stability and remember
// PROBLEM: Lambda captures 'count', which changes.
// Every recomposition creates a new lambda instance.
@Composable
fun Parent() {
var count by remember { mutableIntStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) { Text("Inc") }
// Unstable lambda -- recreated every recomposition
Child(onClick = { doSomething(count) })
}
}
// FIX: Use rememberUpdatedState for callbacks
@Composable
fun Parent() {
var count by remember { mutableIntStateOf(0) }
val currentCount by rememberUpdatedState(count)
Column {
Text("Count: $count")
Button(onClick = { count++ }) { Text("Inc") }
val onClick = remember {
{ doSomething(currentCount) }
}
Child(onClick = onClick) // Stable reference
}
}
// For list items: use key-based remember
@Composable
fun ItemList(
items: ImmutableList<Item>,
onItemClick: (String) -> Unit
) {
LazyColumn {
items(items, key = { it.id }) { item ->
val onClick = remember(item.id) {
{ onItemClick(item.id) }
}
ItemRow(item = item, onClick = onClick)
}
}
}Lazy Layout Performance
// BAD: No keys -- items recompose on any list change
LazyColumn {
items(users) { user -> UserRow(user) }
}
// GOOD: Keys enable efficient diffing
LazyColumn {
items(users, key = { it.id }) { user ->
UserRow(user)
}
}
// BAD: Nested scrolling composes ALL items
Column(Modifier.verticalScroll(rememberScrollState())) {
Text("Header")
LazyColumn { // BROKEN: infinite height
items(users) { UserRow(it) }
}
}
// GOOD: Use LazyColumn's built-in header support
LazyColumn {
item {
Text(
"Team Members",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(Spacing.md)
)
}
items(users, key = { it.id }) { user ->
UserRow(user)
}
}Profiling with Layout Inspector
// Enable Compose compiler metrics
android {
composeCompiler {
reportsDestination = layout.buildDirectory
.dir("compose_metrics")
metricsDestination = layout.buildDirectory
.dir("compose_metrics")
}
}
// Run: ./gradlew assembleRelease
// Check build/compose_metrics/*-composables.txt
//
// Look for:
// restartable fun UserRow(
// unstable user: User <-- FIX THIS
// unstable onClick: Function0 <-- AND THIS
// )
//
// Target: "restartable skippable" for all composablesKey Takeaways
- 1Stability determines whether Compose can skip recomposition -- fix unstable parameters first.
- 2Use ImmutableList from kotlinx.collections.immutable or @Immutable wrappers for collection parameters.
- 3derivedStateOf prevents recomposition storms from frequently-changing inputs like scroll position.
- 4Stabilize lambdas with remember to prevent child composables from recomposing unnecessarily.
- 5Always provide keys in LazyColumn/LazyRow for efficient item tracking.
- 6Use Layout Inspector recomposition counts and Compose compiler reports to identify bottlenecks.
Frequently Asked
Why is my Compose UI janky?
Common causes: unstable parameters causing excessive recomposition, expensive calculations in composable body, large recomposition scope. Use recomposition highlights to identify problem areas.
When should I use remember vs derivedStateOf?
Use remember for values that should persist across recompositions. Use derivedStateOf for values calculated from other state. derivedStateOf only recalculates when inputs change.
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.