There are few things more frustrating in Android development than a jittery scroll. You’ve migrated your RecyclerView to a Jetpack Compose LazyColumn for cleaner code, but now the UI stutters, frames are skipped, and the Layout Inspector shows your list items flashing like a disco.
This isn't just a cosmetic annoyance; it drains the user's battery and degrades the perceived quality of your application.
The culprit is almost always unnecessary recomposition. In LazyColumn, this issue is amplified because scrolling triggers rapid state changes. If your Composables are not "skippable," or if you accidentally trigger a state write during the composition phase, you enter a recomposition loop.
This guide analyzes the root causes of these performance killers and provides the architectural patterns required to fix them.
The Root Cause: Stability and Equality
To fix recomposition loops, you must understand how Compose decides when to redraw a UI element.
Compose uses a concept called Stability. When a Composable function is called with new parameters, the Compose runtime checks if the parameters have changed.
- Stable: The inputs are unchanged (or Compose knows how to track changes), so execution is skipped.
- Unstable: The inputs might have changed, or Compose cannot verify them, so the function re-runs (recomposes).
The Three Silent Killers
In a LazyColumn, three specific anti-patterns force Compose to mark your items as unstable, triggering excessive redraws on every scroll pixel:
- The Collection Interface Trap: Using standard
List<T>parameters. - Unstable Lambdas: Passing inline functions
{ onClick(id) }. - Missing or unstable Keys: Relying on list indices instead of unique IDs.
The Scenario: A Janky User List
Let's look at a typical implementation that compiles perfectly but fails the performance test.
The Problematic Code
// ❌ BAD: This will stutter on scroll
data class User(
val id: String,
val name: String,
var isFavorite: Boolean // Mutable property breaks stability!
)
@Composable
fun UserList(
users: List<User>, // List interface is unstable
onFavoriteToggle: (String) -> Unit
) {
LazyColumn {
items(users) { user -> // No Key provided
UserRow(
user = user,
// Inline lambda creates a new object reference every pass
onToggle = { onFavoriteToggle(user.id) }
)
}
}
}
@Composable
fun UserRow(user: User, onToggle: () -> Unit) {
// heavy UI rendering...
}
Why This Fails
List<User>is Unstable: In Kotlin,Listis an interface. The underlying implementation could be mutable (e.g.,ArrayList). Compose cannot guarantee it hasn't changed, so it recomposesUserListand its children every time the parent recomposes.varin Data Class: SinceisFavoriteis mutable (var), Compose considers theUserclass unstable. It doesn't know when that property changes, so it defaults to always recomposing.- Inline Lambdas: Every time
UserListrecomposes, the lambda{ onFavoriteToggle(user.id) }is reallocated. Since the lambda object reference changes,UserRowsees a "new" input and recomposes.
The Solution: Enforcing Stability and Immutable Collections
To solve this, we need to guarantee "Strong Stability." We will use the Kotlinx Immutable Collections library and refactor our lambdas.
Prerequisites
Add the immutable collections library to your libs.versions.toml or build.gradle.kts:
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
Step 1: Secure the Data Model
Change your data class to be immutable. If you need to change a property, use .copy() in your ViewModel to create a new instance.
// ✅ GOOD: Immutable and marked Stable
@Immutable
data class User(
val id: String,
val name: String,
val isFavorite: Boolean
)
Adding the @Immutable annotation (or @Stable) is a promise to the compiler that once this object is created, its public properties will never change.
Step 2: Use ImmutableList
Replace standard List with ImmutableList. The Compose compiler treats ImmutableList as a stable type because it guarantees the contract of immutability.
Step 3: Defer Lambdas and Fix Keys
We need to ensure that the lambda passed to UserRow does not change between recompositions unless necessary. We also need to provide a stable key to the items builder so Compose can track item moves properly.
The Optimized Code
Here is the production-grade implementation:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import kotlinx.collections.immutable.ImmutableList
@Composable
fun OptimizedUserList(
users: ImmutableList<User>,
onFavoriteToggle: (String) -> Unit
) {
LazyColumn {
items(
items = users,
// 1. Explicit Key: Prevents recomposition if order changes
key = { user -> user.id },
contentType = { "user_row" } // Optimization for item recycling
) { user ->
// 2. Event Wrapper: Prevents lambda reference churn
// We pass the generic handler down, or wrap it in a class
// that implements a functional interface to ensure equality.
UserRow(
user = user,
onToggle = onFavoriteToggle
)
}
}
}
@Composable
fun UserRow(
user: User,
onToggle: (String) -> Unit // Accept the ID consumer directly
) {
// Render UI
// When clicking: onToggle(user.id)
}
Deep Dive: Lambda Instability
You might notice in the optimized code above, I changed UserRow to accept (String) -> Unit instead of () -> Unit. Why?
If UserRow takes () -> Unit, you are forced to write this in the parent:
UserRow(
user = user,
onToggle = { onFavoriteToggle(user.id) } // ⚠️ New object allocation!
)
In Kotlin, that brace syntax { ... } creates a new anonymous object every time the code runs.
By changing the signature of UserRow to accept the function reference onToggle (which is stable coming from the ViewModel or parent), we avoid allocating a new object.
The Method Reference Approach
If you absolutely must pass a lambda that captures state (like the ID), you should use remember to cache the lambda instance, though this can get verbose inside a LazyColumn.
A cleaner pattern for complex interactions is defining a stable interface:
@Stable
interface UserActions {
fun onToggle(id: String)
fun onDelete(id: String)
}
Passing this single, stable interface implementation to your list items guarantees that function references never trigger a recomposition.
Debugging Recomposition: The "Flash" Test
How do you verify your fix worked?
- Enable Layout Inspector: In Android Studio, go to Tools > Layout Inspector.
- Enable Recomposition Counts: In the inspector options, check "Show Recomposition Counts".
- Scroll the List:
- Before Fix: You will see the count on
UserRowincrementing rapidly as you scroll, and the boxes flashing rainbow colors. - After Fix: As you scroll, recycled items will show a count increase, but items that are simply moving pixels on the screen (without data changes) should not flash or increment their recomposition count.
- Before Fix: You will see the count on
Edge Case: Derived State Loops
A specific type of infinite loop occurs when you read state, perform a calculation, and modify state within the composition block.
The Anti-Pattern:
LazyColumn {
items(users) { user ->
// ❌ NEVER DO THIS
if (listState.firstVisibleItemIndex > 5) {
viewModel.loadMoreData() // Side effect in composition!
}
}
}
This causes a loop:
- Composition reads
firstVisibleItemIndex. - Logic triggers
loadMoreData. - State updates.
- Recomposition triggers.
- Go to step 1.
The Fix: Use LaunchedEffect or snapshotFlow to observe state changes safely.
val listState = rememberLazyListState()
// ✅ Monitor scroll state outside the composition phase
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.collect { index ->
if (index > threshold) viewModel.loadMoreData()
}
}
LazyColumn(state = listState) { ... }
Conclusion
High-performance scrolling in Jetpack Compose isn't magic; it is strictly adhering to the contract of Stability.
By using ImmutableList, annotating your data models, providing explicit unique keys, and avoiding inline lambda allocations, you transform a jittery LazyColumn into a buttery smooth experience. Remember: if the inputs haven't changed, the function shouldn't run. Make it easy for the compiler to see that.