Why Is Your Composable Recomposing Like Crazy? A Debugging Guide
The Symptom: List Scrolling Like a Slideshow
The story always starts the same way: PM says "why does this list scroll like a PowerPoint presentation?" You open Profiler and see recomposition count spiking at hundreds per second.
The problem is often not the list itself, but some innocent-looking ViewModel or state update triggering recomposition across a huge subtree. Let us debug this systematically.
Step 1: Enable Recomposition Highlighting
Since Android Studio Hedgehog, Layout Inspector can highlight Composables that are recomposing. Go to Layout Inspector settings and turn on "Show Recomposition Counts". Then interact with your UI and watch which areas are flashing like a disco.
If a component that should not be changing keeps recomposing, that is your suspect.
Step 2: Compose Compiler Metrics
Enable compiler metrics in your build.gradle:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
metricsDestination = layout.buildDirectory.dir("compose_compiler")
}After building, check the generated .txt files. Watch for two types of functions:
- restartable but not skippable: These run every time their parent recomposes, even if their parameters have not changed
- unstable parameters: Parameter types that Compose cannot prove are stable, forcing recomposition
Common Culprit #1: Lambda Captures
Problematic code looks like this:
@Composable
fun UserCard(user: User, viewModel: UserViewModel) {
Button(onClick = { viewModel.onUserClick(user.id) }) {
Text(user.name)
}
}Every time UserCard recomposes, the onClick lambda is a new instance (because it captures external variables), so Button cannot skip. Fix: wrap the lambda in remember:
@Composable
fun UserCard(user: User, viewModel: UserViewModel) {
val onClick = remember(user.id) { { viewModel.onUserClick(user.id) } }
Button(onClick = onClick) {
Text(user.name)
}
}Common Culprit #2: Unstable Data Classes
Compose determines if parameters changed by checking type "stability". List, Map, and other collection types are unstable by default — even if their contents have not changed, they trigger recomposition.
// UNSTABLE — recomposes every time
data class ScreenState(
val items: List<Item> // List is unstable
)
// STABLE — can skip correctly
@Immutable
data class ScreenState(
val items: ImmutableList<Item> // from kotlinx.collections.immutable
)Add the kotlinx-collections-immutable library and swap List for ImmutableList, Map for ImmutableMap. Or annotate your data class with @Immutable (but you must guarantee it truly never mutates).
Common Culprit #3: Uncached Derived State
If you compute something inside a Composable, it runs on every recomposition:
// ❌ Filters on every recomposition
@Composable
fun FilteredList(items: List<Item>, filter: String) {
val filtered = items.filter { it.name.contains(filter) }
LazyColumn { items(filtered) { ... } }
}
// ✅ Cache with remember
@Composable
fun FilteredList(items: List<Item>, filter: String) {
val filtered = remember(items, filter) {
items.filter { it.name.contains(filter) }
}
LazyColumn { items(filtered) { ... } }
}Last Resort: CompositionLocal Abuse Check
If a CompositionLocal value changes frequently, every Composable reading it will recompose. This often happens when you shove an entire ViewModel or large state object into CompositionLocal.
Solution: Only pass truly global values (like theme or locale) through CompositionLocal. Everything else should use parameter passing or dependency injection.