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.

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.

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.