LazyColumn is not a faster Column, and items(key = …) is not optional

LazyColumn composes only visible items and reuses slots as you scroll. Without a stable key that reuse attaches state to the wrong rows — the same positional-identity bug as Day 8, at scale.

6 min read
androidcomposekotlinlayout

Day 23 — LazyColumn is not a faster Column, and items(key = …) is not

Day 23 of 100. Every list in every app. Also the single richest source of Compose performance bugs, almost all of them from two mistakes.

The symptom

A list that works, then doesn't:

LazyColumn {
    items(messages) { message ->
        var expanded by remember { mutableStateOf(false) }
        MessageRow(message, expanded, onToggle = { expanded = !expanded })
    }
}

Expand a message, scroll down, scroll back — a different message is expanded. Delete one and the expansion jumps. Add an item animation and it plays on the wrong row.

Day 8 diagnosed this in a forEach. In a LazyColumn it's worse, because lazy layouts reuse slots aggressively by design, so the symptom is intermittent and scroll-dependent rather than reproducible on demand.

Why the obvious fix fails

The obvious fix is to hoist the expansion state out:

val expandedIds = remember { mutableStateMapOf<String, Boolean>() }
LazyColumn {
    items(messages) { m -> MessageRow(m, expandedIds[m.id] == true) { … } }
}

This fixes the expansion, and leaves everything else broken. Scroll position still jumps when the list changes. animateItem() still animates the wrong rows. A TextField inside a row still loses focus to its neighbour. Each of those needs its own hand-rolled identity map, and you're re-implementing what one parameter does.

Lazy layouts compose only the visible window and reuse slots; key binds a slot to its data

The actual mechanism

LazyColumn differs from Column in one way that matters: it composes only the items in the visible window (plus a small buffer), and disposes items that scroll far enough away. A Column with a verticalScroll composes all ten thousand children immediately.

That reuse needs an identity model, and the default is positional — "the third item slot" — for exactly the reason Day 8 gave. key replaces it with one you choose:

LazyColumn {
    items(messages, key = { it.id }) { message -> … }
}

Four things start working at once, which is why this parameter is worth treating as mandatory rather than an optimisation:

State attaches to data. remember inside an item now belongs to that message.

Scroll position survives list changes. Insert an item above the viewport and Compose keeps the same item on screen, because it can identify it. Without a key, the list jumps.

Item animations become correct. Modifier.animateItem() can distinguish "moved" from "replaced" only if items have identity.

Focus survives. A focused TextField in a row keeps focus across a list update.

The key must be stable across recompositions and unique, and it must be saveable — it goes through the same bundle machinery as rememberSaveable, so a String, Int or Parcelable id is right and a lambda or a whole domain object is not.

contentType, the second parameter nobody sets

Lazy layouts reuse a disposed item's composition for a new item — but only if the two have the same structure. contentType tells Compose which items are interchangeable:

LazyColumn {
    items(feed, key = { it.id }, contentType = { it::class }) { entry ->
        when (entry) {
            is Header -> HeaderRow(entry)
            is Post   -> PostRow(entry)
            is Ad     -> AdRow(entry)
        }
    }
}

Without it, every item is one anonymous type, so scrolling from a PostRow into a HeaderRow cannot reuse the slot's structure and pays a full composition. In a mixed feed that's a measurable scroll cost, and it's one lambda to fix.

The DSL is not a loop

A subtle one that produces genuinely confusing behaviour:

LazyColumn {
    // WRONG — this is a plain Kotlin loop inside the DSL
    messages.forEach { m -> item { MessageRow(m) } }

    // RIGHT
    items(messages, key = { it.id }) { m -> MessageRow(m) }
}

LazyListScope is a builder, not a composable scope. The forEach version creates one item block per message eagerly, so the laziness is gone — you've built ten thousand item definitions to display ten. items() registers a count and an index-to-content function instead, which is what allows the window to be computed without touching every element.

The same builder nature is why you can't call composables directly in the LazyColumn block — everything must be inside item { } or items { }.

Nesting: don't, and what to do instead

A LazyColumn inside a LazyColumn on the same axis throws — an infinite-height constraint inside an infinite-height parent has no meaning. Fixing it with Modifier.height(500.dp) compiles and defeats the purpose: the inner list is now a fixed viewport that composes everything.

The right answer is one lazy list with multiple sections:

LazyColumn {
    item { Header() }
    items(recent, key = { it.id }, contentType = { "recent" }) { RecentRow(it) }
    stickyHeader { SectionHeader("All") }
    items(all, key = { it.id }, contentType = { "all" }) { AllRow(it) }
}

A horizontal LazyRow inside a vertical LazyColumn is fine — different axes, and the common carousel pattern.

How to prove it

The key bug is directly observable:

items(messages) { m ->                       // no key
    val id = remember { m.id }               // captured on FIRST composition
    Text("row shows ${m.id}, slot remembers $id")
}

Scroll far enough for reuse and the two ids diverge on screen. Add key = { it.id } and they never do. That divergence is the bug, made visible.

For the laziness claim, log inside the item body and scroll — you should see compositions only for items entering the window. If you see all of them at once, either you're in a Column or something above forced an unbounded height.

Layout Inspector's recomposition counts on a LazyColumn are the standard performance check: a row recomposing on every frame while scrolling usually means a lambda or unstable parameter is changing per frame, which is Day 7 with a list attached.

What this generalizes to

Every virtualised list in every UI framework has landed on the same two ideas: render a window rather than the whole collection, and require a stable key so recycled containers can be matched to data. RecyclerView had getItemId and getItemViewType; React lists have key; SwiftUI has Identifiable.

The names differ, the failure mode is identical, and it's always diagnosed the same way — state that belongs to one row appearing on another. Recognising that signature saves you the debugging session, in any framework.

One more thing worth setting

LazyColumn takes contentPadding, and the distinction from Modifier.padding matters for anything that scrolls:

LazyColumn(
    contentPadding = PaddingValues(16.dp),          // scrolls WITH the content
    verticalArrangement = Arrangement.spacedBy(8.dp),
    modifier = Modifier.padding(16.dp),             // shrinks the viewport itself
)

Modifier.padding shrinks the visible window, so items are clipped at a padded edge as they scroll past. contentPadding keeps the window full-size and insets the content within it, so the first item starts below the padding and the last can scroll clear of a bottom bar. For any list that scrolls under a system bar or a FAB, contentPadding is the correct one — and it's the one that gets forgotten.

Tomorrow, Day 24: LazyVerticalGrid, span sizing, and why a grid is not just a list with two columns.


Day 23 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Lists and grids.