List Performance and Recomposition
Lazy lists are one of the most common places where Compose apps start to feel janky, because a list is exactly the kind of UI that recomposes constantly: items scroll in and out, data refreshes from the network, and every keystroke in a search box can ripple through hundreds of rows. Understanding how Compose's recomposition scope tracking interacts with LazyColumn and LazyRow is the difference between a list that stutters at 30 items and one that stays smooth at 10,000.
Cricket analogy: A packed T20 run chase is like a lazy list under stress, boundaries scroll by, the required run rate refreshes every ball, and every single keystroke of a chasing captain's field change can ripple across the whole XI if not handled carefully.
Compose recomposition is scoped, not global. When a piece of state read inside a composable changes, only the smallest enclosing composable function that read that state is scheduled to recompose — not the whole tree. Inside a lazy list, each item lambda passed to items() is itself a composable scope, so in the ideal case only the rows whose underlying data actually changed get recomposed, while the rest are skipped entirely. Getting to that ideal case depends on giving Compose the information it needs: stable data types, stable lambdas, and item keys.
Cricket analogy: Scoped recomposition is like a scoreboard operator updating only the batter's individual score tile when they hit a boundary, not repainting the entire scoreboard including bowling figures and the run rate.
Why keys matter
By default, LazyColumn identifies items by their position in the list. If you insert or remove an item in the middle of the list without supplying a key, Compose has no way to tell that item 5 is 'the same item that moved from position 4' — it just sees that the content at each index changed, and it may recompose, rearrange, or even reset the internal state (like scroll position within a nested composable, or remember blocks) of every item after the mutation point. Supplying a stable, unique key for each item (typically a database ID) lets Compose track identity across list mutations, so animations, remembered state, and focus follow the item rather than the index.
Cricket analogy: Missing a stable key on a batting-order list is like reordering a lineup mid-innings and having the app confuse batter 5 with batter 4, losing track of who's actually 'not out' and resetting their strike rate.
data class Task(val id: Long, val title: String, val done: Boolean)
@Composable
fun TaskList(tasks: List<Task>, onToggle: (Long) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
items = tasks,
key = { task -> task.id },
contentType = { task -> task.done }
) { task ->
TaskRow(
task = task,
onToggle = { onToggle(task.id) }
)
}
}
}
@Composable
fun TaskRow(task: Task, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = task.done, onCheckedChange = { onToggle() })
Spacer(modifier = Modifier.width(12.dp))
Text(text = task.title, style = MaterialTheme.typography.bodyLarge)
}
}Stable types and unstable lambdas
Compose's compiler plugin marks a type as 'stable' if it can prove that instances of it never change in a way that would produce a different UI without notifying Compose — all vals of primitive or other stable types, for example. data class with only val properties of stable types (like Long, String, Boolean) is automatically stable, which is exactly why Task above skips recomposition cheaply. Mutable public var properties, or properties of unstable collection types like plain List<T> (as opposed to Kotlin's immutable-by-contract List combined with @Immutable annotations, or ImmutableList from kotlinx.collections.immutable), can make a class unstable, forcing Compose to recompose it defensively on every parent recomposition even if nothing changed.
Cricket analogy: A stable data class like a val-only Task is like an official scorecard entry, runs, balls, and dismissal type all fixed once recorded, so the system can trust it never silently changes and skip re-verifying it.
Lambdas passed into item composables matter too. A lambda that captures a changing variable is recreated on every recomposition of the parent, and a 'new' lambda instance looks like a changed parameter to Compose's equality check, triggering recomposition of the row that receives it. Prefer passing stable references — such as a method reference on a ViewModel, or a lambda that only captures a stable id — over inline closures that capture volatile local state.
Cricket analogy: A freshly recreated lambda on every recomposition is like a substitute fielder who looks like a totally new player to the scoring system each time they're swapped in, triggering a needless re-check of the whole fielding chart.
Use Android Studio's Layout Inspector 'Recomposition Counts' overlay (or the Modifier.Companion recomposition highlighter in newer Compose versions) to visually confirm which rows are actually recomposing during a scroll or state update — guessing is unreliable, measuring is not.
A very common mistake is calling .filter(), .sortedBy(), or building a new list literal directly inside the composable body on every recomposition. Even if the resulting list has the same logical contents, it's a new List instance each time, which can defeat key-based diffing efficiency and cause unnecessary work. Compute derived lists in the ViewModel or wrap the computation in remember/derivedStateOf keyed on the source data.
contentType and mixed layouts
When a lazy list mixes different row layouts (for example, headers, ads, and regular items), pass a contentType lambda alongside key. Compose's lazy layout implementation reuses composition and measurement slots for items of the same content type as they scroll off and on screen; without a hint, it may try to reuse a slot built for a different layout shape, which is more expensive than allocating a fresh one. Combining key for identity and contentType for slot reuse is the recommended pattern for any non-trivial list.
Cricket analogy: Passing a contentType alongside key for mixed rows is like a groundstaff crew reusing the pitch-roller equipment for another pitch-rolling task but grabbing fresh boundary-rope gear rather than misapplying roller settings to rope laying.
- Recomposition in Compose is scoped — only composables that read changed state re-run, so lazy list rows can skip recomposition if their inputs are unchanged.
- Always supply a stable, unique
key(e.g. a database ID) toitems()so Compose can track item identity across insertions, removals, and reordering. - Data classes with only stable
valproperties are automatically stable; mutablevars or plain mutable collections make a class unstable and force defensive recomposition. - Avoid creating new lambdas or new list instances inside the composable body on every recomposition — hoist derived data computation and prefer stable callback references.
- Use
contentTypealongsidekeyin lists with mixed item layouts so Compose can reuse composition slots efficiently. - Profile with the Layout Inspector's recomposition counts rather than guessing — perceived jank often comes from a single unstable parameter, not the list itself.
Practice what you learned
1. What is the primary purpose of supplying a `key` to `items()` in a LazyColumn?
2. Which of the following makes a Kotlin data class 'unstable' from Compose's compiler-inference perspective?
3. What problem does the `contentType` parameter solve in a mixed-content lazy list?
4. Why is creating a new lambda inside a composable's body on every recomposition potentially costly for list performance?
5. What tool can you use in Android Studio to visually confirm which list rows are actually recomposing?
Was this page helpful?
You May Also Like
LazyColumn and LazyRow
Master Compose's scrollable list primitives, LazyColumn and LazyRow, which render only visible items for efficient, memory-friendly scrolling.
LazyGrid and Item Keys
Explore LazyVerticalGrid and LazyHorizontalGrid for building grid layouts in Compose, and why stable item keys are essential for correctness.
derivedStateOf and Side Effects
Learn how derivedStateOf avoids unnecessary recomposition for computed values, and how effect handlers like LaunchedEffect manage side effects safely.
mutableStateOf and Recomposition
Dive into how mutableStateOf creates observable state that triggers recomposition, and how Compose determines which composables need to redraw.