Android with Kotlin and Jetpack Compose · Compose Foundations: Screens, Layout, Lists and State

Long Lists with LazyColumn

Show thousands of items efficiently with LazyColumn and LazyVerticalGrid, give items stable keys, add sticky section headers, and avoid the mistakes that make lists janky.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

An order history screen with thousands of orders grouped by month, each month with a header that sticks to the top while you scroll, and rows that keep their state when items are added or removed.

The idea

A Column composes every child, which is fine for ten items and terrible for ten thousand. Lazy lists (LazyColumn, LazyRow, LazyVerticalGrid) compose only the items on screen and a few around it, and reuse them as you scroll.

Two details make lazy lists correct and smooth:

  • Stable keys. items(orders, key = { it.id }) tells Compose which row is which. Without keys, items are identified by position, so inserting at the top makes every row's remembered state (expanded, text input, animations) move to the wrong item.
  • Content types. When a list mixes headers and rows, contentType lets Compose reuse compositions of the same kind.

The code

@Composable
fun OrderHistory(sections: List<MonthSection>, onOpen: (Order) -> Unit) {
    LazyColumn(contentPadding = PaddingValues(bottom = 24.dp)) {
        sections.forEach { section ->
            stickyHeader(key = "header-${section.month}", contentType = "header") {
                Text(
                    section.month,
                    style = MaterialTheme.typography.titleSmall,
                    modifier = Modifier
                        .fillMaxWidth()
                        .background(MaterialTheme.colorScheme.surfaceContainer)
                        .padding(horizontal = 16.dp, vertical = 8.dp),
                )
            }
            items(section.orders, key = { it.id }, contentType = { "order" }) { order ->
                ListItem(
                    headlineContent = { Text("Order #${order.id}") },
                    supportingContent = { Text(order.summary) },
                    trailingContent = { Text(order.totalLabel) },
                    modifier = Modifier.clickable { onOpen(order) }.animateItem(),
                )
            }
        }
    }
}

animateItem() animates rows when they're inserted, removed or reordered, which only works with keys. Prepare sections in the ViewModel, not inside the composable, so grouping doesn't rerun on every recomposition.

Try the logic

Grouping orders by month, and what goes wrong when rows are identified by position instead of key:

Grouping by month, and keys vs positions
data class Order(val id: Int, val month: String, val total: Double)
data class MonthSection(val month: String, val orders: List<Order>)

fun sections(orders: List<Order>): List<MonthSection> =
    orders.groupBy { it.month }.map { (month, list) -> MonthSection(month, list.sortedByDescending { it.id }) }

fun main() {
    val orders = listOf(Order(101, "2026-09", 30.0), Order(102, "2026-09", 12.5), Order(103, "2026-10", 99.0))
    for (s in sections(orders).sortedByDescending { it.month }) {
        println("${s.month}: ${s.orders.map { it.id }}")
    }

    // Row 0 is expanded. A new order arrives at the top of the list.
    val before = listOf(103, 102, 101)
    val after = listOf(104, 103, 102, 101)
    val expandedByPosition = 0
    val expandedByKey = before[0]
    println("by position, the expanded row is now order ${after[expandedByPosition]} (wrong)")
    println("by key, the expanded row is still order $expandedByKey")
}

Output

2026-10: [103]
2026-09: [102, 101]
by position, the expanded row is now order 104 (wrong)
by key, the expanded row is still order 103

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • No keys. State and animations jump between rows when the data changes.
  • Nesting a LazyColumn in a vertically scrolling Column. It crashes (infinite height); use one lazy list with different item types.
  • Heavy work in item content. Format dates and totals once in the ViewModel, not per recomposition.
  • Unstable item classes. Lists of mutable classes make Compose recompose rows more often; prefer immutable data classes.

Key points

  • Lazy lists compose only visible items, so they scale to very long data.
  • Always pass a stable key; add contentType for mixed lists.
  • stickyHeader and animateItem() are built in.
  • Prepare list data outside composables.

Further reading: Lists and grids, Compose performance best practices.

Practice questions

Why should items in a LazyColumn have keys?

Answer

Keys give each item a stable identity. Without them items are identified by index, so when data is inserted, removed or reordered, remembered state and animations attach to the wrong rows and Compose can't animate the changes. Keys also let the scroll position stay on the same item.

Why can't you put a LazyColumn inside a Column with verticalScroll?

Answer

The scrolling Column gives its children unbounded height, and a lazy list needs a bounded height to know how many items to compose; Compose throws an error. Use a single LazyColumn and put the other content in it as items.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?