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.
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,
contentTypelets 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:
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
LazyColumnin a vertically scrollingColumn. 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; addcontentTypefor mixed lists. stickyHeaderandanimateItem()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.