A LazyColumn without keys loses your scroll position and your animations
Without a key, item identity is positional — so an insert at the top reuses every slot's state for the wrong item.
TL;DR
items(list) identifies each row by its index. Insert something at position 0 and every item's
identity shifts by one — so Compose believes item 0 changed rather than that a new item appeared.
Everything keyed to item identity then attaches to the wrong row: remember inside the item,
expand/collapse state, text field contents, animation state, and the scroll anchor. The visible
symptoms look unrelated to each other and none of them look like a missing parameter.
items(list, key = { it.id }) fixes all of them at once, and animateItem() only works at all
once you have it.
The case this post is about
A feed where new items arrive at the top, with rows that can be expanded:
@Composable
fun Feed(items: List<Post>) {
LazyColumn {
items(items) { post -> // no key
PostRow(post)
}
}
}
@Composable
fun PostRow(post: Post) {
var expanded by remember { mutableStateOf(false) } // per-item UI state
Column(Modifier.clickable { expanded = !expanded }) {
Text(post.title)
if (expanded) Text(post.body)
}
}
Standard shape. The bugs arrived one at a time over a month and were filed as four separate tickets.
The symptom
The wrong row was expanded after a refresh. Expand item 3, pull to refresh, two new posts arrive at the top — and item 3 is still the expanded one, which is now a different post:
before refresh: [A][B][C*][D] C expanded
after refresh: [X][Y][A][B*][C][D] B expanded ← state stayed at index 2
Scroll position jumped on every refresh. Reading at item 10, two items arrive, and the view stays at index 10 — which is now two posts earlier than where you were.
Item animations did nothing. Adding Modifier.animateItem() had no visible effect on insert or
reorder.
And a text field in an editable row swapped its contents with a neighbour after a sort change, which is the same bug with a more alarming presentation.
Four tickets, one missing parameter.
Why the obvious fix failed
"Hoist expanded into the ViewModel." This does fix the expand bug — and only if you key the
map by post id, which is the same insight the key parameter encodes. I did it keyed by index
first, which moved the bug rather than fixing it:
val expandedIndices = mutableStateSetOf<Int>() // still positional. Same bug.
"Use LazyColumn(state = rememberLazyListState())." Already there. The state remembers a
scroll index, and index is exactly the thing that is no longer stable across an insert.
"Call animateItem() properly." The call was correct. Item animations are driven by identity
changes — without keys there is no identity to diff, so the modifier has nothing to animate
between.
"Sort the list so new items go at the bottom." Avoids the insert-at-top case and changes the product. It also still breaks on reorder, filter and delete.
The actual mechanism
A lazy list maintains a slot table keyed by item identity, and identity is where everything hinges.
Without key, identity is the index. Item at position 2 is "item 2". When the list changes,
Compose diffs by position: it compares new position 2 against old position 2, concludes the content
changed, and reuses the composition that was there — including everything remembered inside it.
remember inside an item is scoped to that slot. So reused slot 2 keeps slot 2's remembered
expanded = true, and the new occupant inherits it. That is not a bug in remember; it is
remember working correctly on a slot the framework believes is the same item.
Scroll anchoring stores an index. LazyListState remembers "first visible item index 10". With
positional identity there is nothing else it could store, so an insert above shifts what index 10
means.
And two ways to add key that do not fix anything, both of which read as correct in review:
items(rows, key = { it }) { … } // the ROW is the key — changes when content changes
items(rows.size, key = { it }) { … } // the INDEX is the key — identical to no key at all
The second is the quiet one. key = { index -> index } satisfies every linter, looks deliberate,
and restores positional identity exactly. The first is subtler: using the whole item as its key
means editing a row's title changes its identity, so Compose discards the composition and the
item's own remembered state resets on every edit — the original bug, triggered by typing instead
of by inserting.
There is also a hard requirement people meet by accident: keys must be unique, and a duplicate
is a runtime crash (IllegalArgumentException: Key ... was already used), not a degraded diff.
That sounds like a drawback and is the opposite — it is the one part of this whole mechanism that
fails loudly. A list keyed on something nearly-unique, like a display name, works through
development and throws on the first user with two saved searches called "Home".
So the key has to be the thing that answers "is this the same row", independent of what the row currently contains — a server id, a UUID minted at creation, a composite of a stable parent id and a stable child id. If no such value exists, that absence is usually the real finding.
Item animations diff identity between frames. No stable identity, no before/after pairing, no animation.

keytells the lazy list what an item is. Without it, "the third row" is the only identity available, and every feature built on identity — remembered state, animation, scroll anchoring — silently tracks position instead.
The fix
One parameter, plus the two things it unlocks.
@Composable
fun Feed(items: List<Post>) {
LazyColumn {
items(
items = items,
key = { it.id }, // identity, not position
contentType = { it.kind }, // helps slot reuse across types
) { post ->
PostRow(post, modifier = Modifier.animateItem())
}
}
}
Four decisions:
key = { it.id } must be stable and unique. A server id is ideal. The two tempting wrong
choices are worth naming: the index (what you already have) and a derived value like
"${post.title}-${post.author}", which breaks the moment a title is edited — the key must identify
the item, not its current content.
contentType is a small bonus. When a list mixes row shapes, giving the type lets Compose reuse
compositions only between items of the same kind, which reduces re-layout churn.
animateItem() now functions, because there is an identity to animate between. It was never
misconfigured.
And per-item state keyed by id, if it must be hoisted:
// in the ViewModel — keyed by identity, never by index
private val expanded = MutableStateFlow(persistentSetOf<String>())
fun toggle(id: String) = expanded.update { if (id in it) it.remove(id) else it.add(id) }
Whether to hoist at all is a separate decision: remember inside the item is fine for ephemeral UI
state once keys are correct — it survives scroll-away-and-back for items still in the lazy cache,
and is lost beyond it. Hoisting is for state that must survive the item leaving composition
entirely.
For scroll anchoring across inserts, keys make the index meaningful again, and for a feed where you want to hold the reading position there is one more step:
// remember the id, not the index, when the position must survive a refresh
val anchorId = remember(listState.firstVisibleItemIndex) {
items.getOrNull(listState.firstVisibleItemIndex)?.id
}
LaunchedEffect(items) {
anchorId?.let { id ->
items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { listState.scrollToItem(it) }
}
}
That is the explicit version of "stay where I was reading", which no amount of keying provides on its own.
How we proved it
That state follows the item. The regression test for the first ticket:
@Test
fun expandedStateFollowsTheItemAcrossAnInsert() {
val items = mutableStateListOf(Post("a", "A"), Post("b", "B"), Post("c", "C"))
composeTestRule.setContent { Feed(items) }
composeTestRule.onNodeWithText("C").performClick() // expand C
composeTestRule.onNodeWithText("body of C").assertExists()
items.addAll(0, listOf(Post("x", "X"), Post("y", "Y"))) // insert at top
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("body of C").assertExists() // still C
composeTestRule.onNodeWithText("body of A").assertDoesNotExist()
}
without key: FAILED — "body of A" exists, "body of C" does not
with key: PASSED
That assertion pair is the useful shape: it checks the right row is expanded and that the wrong one is not, which a single positive assertion would have missed.
That animations began working — observable as a frame-level change rather than a boolean:
insert one item at position 0, 60fps capture
without key: new item appears in 1 frame, others jump (no animation)
with key: new item fades in over 7 frames, others slide (animateItem active)
And that the text field swap is gone:
sort change with an editable row focused
before: field content appears on the neighbouring row
after: content stays with its item
One limit worth stating: keys fix identity, and they do not make scroll position stay on the same content by themselves — the index is now meaningful but still an index, which is why the anchor-id effect above exists as a separate, explicit piece. Conflating those two was the thing that made the scroll ticket take longest.
What this generalizes to
A diffing algorithm is only as good as the identity you give it. React's key, a
DiffUtil.ItemCallback, a database merge key, a reconciliation loop in an operator — all of them
need to be told what makes two things "the same thing". The default is almost always positional,
and positional is almost always wrong for a mutable collection.
Several unrelated-looking symptoms can share one cause. Four tickets across a month: wrong row expanded, scroll jumping, animations inert, text swapping. Anything built on identity fails together when identity is wrong. A cluster of odd behaviours in one component is worth looking at as a set.
A feature that silently does nothing is worse than one that errors. animateItem() compiled,
ran, and had no effect. When a declarative API seems not to work, check its preconditions before
checking its usage.
Key by what a thing is, not by what it currently contains. An id is stable through edits; a hash of the content is not. This is the same reasoning as choosing a primary key, and the same failure when you get it wrong.
Related
- remember is not a cache, and the key is the whole contract — the other keying mistake
- Recomposition is not re-rendering, and that is why your list stutters — what keys do not fix
- Modifier order is not style, it is semantics — another invisible default
Keep reading
This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".