← Jetpack Compose from Zero to Production

Lists, keys and staying at 60 fps

Lesson 3 of 51 min read

LazyColumn done properly: keys, stability, and precomputed rows.

LazyColumn only composes what is visible. That is free performance — until four specific mistakes take it away.

1. Missing keys

LazyColumn {
    items(products, key = { it.id }) { ProductRow(it) }
}

Without a key, items are identified by index. Reorder or remove from the middle and state sticks to positions instead of items: the wrong row stays expanded, checkboxes appear to jump, animations teleport.

2. Work inside the row

Formatting a date, building a string, parsing a price — per row, per scroll, per frame. Precompute it in the ViewModel and give the row a ready-to-draw model.

data class ProductRowModel(val id: String, val title: String, val priceLabel: String)

3. Unstable parameters

If Compose cannot prove a parameter is stable, it cannot skip the composable. A plain List is treated as unstable. Use an immutable collection type, or mark the model @Immutable when you can guarantee it.

4. Reading state too high

Reading a scroll offset or an animated value in a parent recomposes the parent's whole subtree. Read it in the smallest composable that needs it, or pass a lambda so the read happens later.

// recomposes the screen on every scroll pixel
val offset = listState.firstVisibleItemScrollOffset

// reads only where it is drawn
Header(alphaProvider = { computeAlpha(listState) })

How you prove it

  • Layout Inspector shows recomposition counts per composable — a number climbing while you scroll a static area is your bug.
  • Macrobenchmark with FrameTimingMetric gives you a number before and after.
  • Always measure a release build; debug Compose is meaningfully slower and will mislead you.

The usual mistake: loading a full-resolution image into a 96 dp row. Ask Coil for the display size and the jank disappears before you optimise anything else.