Why Your Compose List Redraws on Every Keystroke
A search box above a list makes the whole screen stutter. Here is how to see which composables are recomposing, the four reasons it happens, and the fix for each one.
You add a search field above a list of two hundred items. You type, and every keystroke makes the list flicker and the frame times spike. The list is not even changing yet, so why is it redrawing?
Compose recomposes a composable when a state value it reads changes, and when it cannot prove the inputs are the same as last time. Both halves of that sentence cause this bug. Before changing anything, measure.
Step one: see the recompositions
Android Studio's Layout Inspector has a recomposition counter. Open your app, choose the Layout Inspector, and enable the recomposition count column. Type one character in the search field and read the counts. You are looking for a composable whose count climbs when it had no reason to change: the list, or a row inside it.
For a view of the whole module, turn on the Compose compiler reports in your module's build file:
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
Build the module and read the generated report. Every composable is marked restartable and skippable or not. A composable that is restartable but not skippable will recompose whenever its parent does, no matter what you pass it. Those are your suspects.
Cause one: the state sits too high
This is the most common version by far.
@Composable
fun SearchableCourses(courses: List<Course>) {
var query by remember { mutableStateOf("") } // read in this scope
Column {
TextField(value = query, query = it })
CourseList(courses) // pays for every keystroke
}
}
query is read inside SearchableCourses, so the whole function recomposes on every character, and CourseList goes with it. Move the state down into the only composable that reads it, or pass the value as a lambda so the read happens lower in the tree:
@Composable
fun SearchableCourses(courses: List<Course>) {
val state = remember { SearchState() }
Column {
SearchField(state) // reads state.query
CourseList(courses) // never reads it
}
}
The rule of thumb: a state value should be read as deep in the tree as it can be. Hoisting state is good for correctness and bad for performance when you hoist it further than the logic needs.
Cause two: the parameters are unstable
Compose skips a composable only when it can prove every parameter is equal to last time. It can do that for primitives, for String, for stable types, and for types it can see are immutable. It cannot do it for a type whose equality it cannot reason about.
Two things in everyday code break stability:
- A
varin a data class. A class with a mutable property that is not a Compose state is unstable, and everything that takes it is unskippable. - Collection interfaces.
List<Course>is an interface: the implementation behind it could be a mutable list, so the compiler treats it as unstable unless the module is built with strong skipping.
Fixes, in the order worth trying:
// 1. Make the model genuinely immutable
data class Course(val id: String, val title: String, val fee: Int)
// 2. Use an immutable collection type for parameters
// implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:…")
@Composable
fun CourseList(courses: ImmutableList<Course>) { /* … */ }
// 3. As a last resort, promise stability yourself
@Immutable
data class Filters(val level: String, val tags: List<String>)
@Immutable is a promise the compiler trusts without checking. If you annotate a type and then mutate it, you will get a screen that does not update, which is a far harder bug than a slow one. Use it only on types you are sure about.
Cause three: a lambda allocated on every composition
A lambda that captures something unstable is itself unstable, so passing it defeats skipping:
CourseRow(
course = course,
viewModel.open(course.id) } // new object each composition
)
Compose remembers lambdas for you when it can, but captures of unstable values stop that. Pass a method reference, or hoist the lambda with remember keyed on what it captures:
val { { id: String -> viewModel.open(id) } }
CourseRow(course = course,>
Cause four: no keys in the list
Without keys, LazyColumn identifies items by position. Filter the list and every item below the change gets a new position, so their state and their composition are thrown away:
LazyColumn {
items(courses, key = { it.id }) { course ->
CourseRow(course)
}
}
Keys cost nothing and fix list flicker, lost scroll positions and animations that restart. There is no reason to leave them out.
Filtering: derive, do not recompute
If the filtered list is computed in the composable body, it runs on every recomposition. Wrap it so it only runs when the inputs change:
val visible by remember(courses) {
derivedStateOf { courses.filter { it.title.contains(state.query, ignoreCase = true) } }
}
derivedStateOf earns its keep when the input changes more often than the output: the query changes on every keystroke, but the filtered list often does not change at all between two characters.
What good looks like
After the fixes, type a character and watch the counter: the text field recomposes, the list does not, and the rows recompose only when their own data changes. That is the whole goal. Compose is fast when it can skip work, and almost every performance problem in a Compose screen is a story about why it could not.
One last warning worth repeating: measure on a release build with R8 enabled. A debug build recomposes more and runs slower, and the numbers you chase there are not the numbers your users see.