Choosing a Key Strategy for Jetpack Compose LazyColumn
A decision guide for the LazyColumn key parameter: when stable IDs matter, why index keys are a trap, and how to verify state survives inserts and reorders.
03 Jul 2025, 00:31 UTC

When a LazyColumn's underlying list changes — items inserted at the top, reordered, or removed — Compose has to decide which on-screen item corresponds to which data object. Get that mapping wrong and you see checkboxes jumping to the wrong row, scroll positions resetting, or every visible item recomposing on each update. The key parameter on items() is how you control that mapping. The decision is which key strategy fits your data, and the wrong choice is worse than none at all.
The decision and its constraints
Compose uses keys to match items across recompositions. With a matching key, it reuses the existing composition (and its remembered state, like a rememberSaveable checkbox or an in-progress animation) for the same logical item even when its position changes. Without a key, Compose falls back to positional matching: the item at index 3 is treated as the same item that was at index 3 before, which breaks as soon as anything is inserted above it.
The constraints that drive the decision:
- Keys must be unique within the list and stable for a given logical item across recompositions.
- The key type must be saveable in a Bundle if you want state to survive process death (primitives and Strings are safe).
- Deriving keys must be cheap, because it runs during composition of the list.
Comparing the supported options
| Strategy | State preserved on reorder/insert | Recomposition cost | Risk |
|---|---|---|---|
| No key (default) | No — positional matching | Higher; shifted items recompose and lose state | Visual glitches on any non-append change |
key = { it.id } (stable ID) | Yes | Lowest; only affected items recompose | Requires unique, immutable IDs from your data source |
key = { index } | No — equivalent to positional | Same as no key | Gives a false sense of safety; state mix-ups on reorder |
| Content hash or generated UUID | Partially | Key-generation overhead per composition | Collisions (hash) or instability (UUID regenerated each time) |
Two clarifications on that table. First, key = { index } is not a middle ground — it is functionally the default with extra steps, and it actively misleads the next reader of the code. Second, a UUID generated inside the key lambda is broken in the opposite direction: it changes every recomposition, so Compose treats every item as new every time, resetting all state and forcing full recomposition. If your data has no natural ID, generate the UUID once when the item is created and store it on the model, or use a content hash only if content is guaranteed unique.
Implementation with a stable ID
Assume a Room-backed or API-backed model where each item already has a database ID — this is the common case and the one Compose is designed around:
data class TaskItem(
val id: Long, // stable, unique, from the database
val title: String,
val done: Boolean
)
@Composable
fun TaskList(viewModel: TaskViewModel = viewModel()) {
val tasks by viewModel.tasks.collectAsStateWithLifecycle()
LazyColumn {
items(
items = tasks,
key = { it.id },
contentType = { "task" }
) { task ->
TaskRow(
task = task,
onToggle = { viewModel.toggle(task.id) }
)
}
}
}Inside TaskRow, keep ephemeral UI state (like an expand/collapse flag) in rememberSaveable. Because the key is stable, that state now follows the item when the list reorders instead of staying glued to a position. The contentType hint is optional but cheap: it helps Compose reuse compositions between items of the same type, which matters if your LazyColumn mixes row layouts.
If you also want item animations, stable keys are a prerequisite — Modifier.animateItem() (formerly animateItemPlacement()) relies on keys to detect moves. This is another reason to prefer real IDs over skipping keys entirely.
Verifying the behavior
You can confirm the key is doing its job without special tooling:
- Build a list of ~100 items, each row containing a checkbox whose state is held in the row with
rememberSaveable(not hoisted to the ViewModel — that is the state you are testing). - Scroll partway down, toggle a checkbox on a visible item, and note the item's ID.
- Insert a new item at index 0 via the ViewModel. With
key = { it.id }, the toggled checkbox stays on the same logical item, which has now shifted down one position. Without a key, the checkbox stays at the same position and now appears on the wrong item. - For a finer check, open Layout Inspector in Android Studio and enable recomposition counts. Update one item's text: with stable keys, only that row's recomposition count increments; with positional matching after an insert, many rows recompose.
A Compose UI test can automate the insert-and-check assertion: toggle a node identified by its item's test tag, prepend an item to the state holder, then assert the tagged node is still checked. Keep the test on an emulator or device with animations disabled for determinism.
Limitations to keep in mind
- Keys fix identity, not performance problems elsewhere. If your row composable takes unstable lambda parameters or reads state too broadly, it will still recompose excessively.
- A key that changes for the same logical item (for example, derived from a mutable
title) resets that item's state and can cause flicker — worse than no key, because it fails intermittently. - Duplicate keys crash at runtime with an illegal argument exception, so enforce uniqueness at the data layer rather than hoping.
- For very large lists, avoid computing keys from expensive operations (like hashing large content) inside the key lambda; precompute and store the key on the model.
The practical rule: if your items come from a database or API, use the existing ID and stop thinking about it. Only reach for alternatives when there is genuinely no stable identifier, and treat index-based keys as a bug, not a fallback.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.