Hey Composers š,
Itās awesome to see Jetpack Compose being adopted in so many apps. If youāre using Compose, youāve almost certainly worked with its powerful LazyList APIs like LazyColumn and LazyRow. They are incredibly efficient for displaying list of items.
However, thereās a tiny parameter in the items function that can cause some seriously confusing behavior if overlooked. In this blog, weāre going to explore how missing one small thing: the key and how it can lead to tricky state management issues.
Preface
A while back, I posted a poll on X and LinkedIn asking developers a simple question:

The results were interesting and mostly similar across both platforms!
- š¢ Always use ā 60%
- š” Sometimes ā 32%
- š“ Never/Didnāt know ā 8%
This poll inspired me to write this post to shed some light on this important detail especially for 40% of Android developers.
Before we dive into the problem, letās quickly understand what this key parameter actually does.
What is key {} in LazyColumn
When you provide a list of items to a LazyColumn or LazyRow, you can also provide a key for each item. This key is a stable and unique identifier for that specific piece of data.
LazyColumn {
items(
items = myItems,
key = { item -> item.id } // Provide a unique ID for each item
) { item ->
MyItemRow(item)
}
}
Think of it like a primary key in a database table. It gives Compose a way to track each item individually, even if the list changes.
Why is this important?
Performance Boost š: When you add, remove, or reorder items in your list, Compose uses these keys to understand which items have changed. This allows it to be much smarter. For example, if you reorder items, Compose can just move the corresponding composables without completely redrawing them. This avoids unnecessary work and makes your app feel smoother.
Stable Identity:
The key tells Compose, āHey, this item is the same one as before, itās just in a different position now.ā Remember that DiffUtil.ItemCallback in RecyclerView? š¤
What happens if you donāt specify a key? If you skip the key, Compose falls back to using the itemās position (or index) in the list as its identifier. For a list that never changes, this is perfectly fine. But for a dynamic list where items can be added or removed, this can lead to some unexpected problems. Because whenever the list changes, even if some items in the list might not have been changed, itāll still cause recompositions for such items.
The most important rule for keys is that they must be unique. If two items have the same key, your app will crash with an error.
Now, letās get to the fun part and see what can go wrong.
What can go wrong if key is missing
Letās imagine a simple scenario. We have a LazyColumn showing a list of fruits. Each Fruit item is a composable that can be updated. When you tap the ā+ā or ā-ā buttons, it changes a quantity, and the itemās background highlights to show it has been modified.
Hereās what the UI for a single fruit item looks like:

And here is the code for our Fruit composable:
@Composable
fun Fruit(fruit: Fruit, modifier: Modifier = Modifier) {
Row(/* ... */) {
var quantity by remember { mutableIntStateOf(0) }
var hasChanged by remember { mutableStateOf(false) }
Text(fruit.emojifiedImage, /* Other parameters */)
Column(/* ... */) {
Text(fruit.name)
Row {
Button(onClick = { quantity-- }) { Text("-") }
Button(onClick = { quantity++ }) { Text("+") }
}
}
if (hasChanged) {
Box(Modifier.background(MaterialTheme.colorScheme.tertiary)) {
Text("$quantity", /* Other parameters */)
}
}
// š Pay attention to this. This only notifies whether any changes have been made
// to this fruit or not.
LaunchedEffect(Unit) {
snapshotFlow { quantity }.filter { it != 0 }.first()
hasChanged = true
println("${fruit.name} has changed")
}
}
}
Notice that the quantity and hasChanged states are managed inside the Fruit composable using remember {}. hasChanged is a boolean state which is being mutated only from LaunchedEffect only when quantity is updated for the first time, then prints a simple log to console and it leaves the block. Since underneath itās using a snapshotFlow {}, thereās no need to specify a key to LaunchedEffect.
Finally, we display the list on a screen and add a button to remove the first item. We are not providing a key in our LazyColumn.
@Composable
fun DemoScreen() {
var fruits by remember { mutableStateOf(sampleFruits) }
Column(/* ... */) {
OutlinedButton(onClick = { fruits = fruits.removeAt(0) }) {
Text("Remove first")
}
LazyColumn(/* ... */) {
items(fruits) {
Fruit(it)
}
}
}
}
Now, letās perform a few actions on the UI:
- Click + on Apple -> logs
Apple has changed - Click āRemove firstā on top -> It removed the first item from list i.e. Apple. -> Now Banana is in 1st place. But itās still keeping the state of old item along with highlighted background.
- Click + on Orange -> logs
Grape has changed - Click + on Peach -> logs
Strawberry has changed
See it here:
Surprised? 𤯠This shouldnāt have happened right?
This strange behavior happens because of how Compose reuses composables.
When you donāt provide a key, Compose identifies each item by its position. In our example, the āAppleā item was at position 0. It had its own internal state (quantity and hasChanged) that we modified.
When we removed āAppleā from our data list, āBananaā moved into position 0. From Composeās perspective, it sees that there is still a composable at position 0. To be efficient, it decides to reuse the existing composable and just give it the new data (āBananaā instead of āAppleā).
The problem is that the remembered state from the old item is still attached to that composable āslotā at position 0. So, āBananaā inherits the quantity and hasChanged state that originally belonged to āAppleā.
In ideal situations, when the state comes from the ViewModel, UI state-related issues wonāt be noticeable. However, the concern here is with the behavior of LaunchedEffect. If youāre calling some business logic from side-effect APIs or using anything related to it, it can be confusing.
The Simple Solution: Provide a key
This entire problem can be fixed with a single line of code. We just need to tell Compose how to uniquely identify each fruit.
LazyColumn(/* ... */) {
- items(fruits) {
+ items(fruits, key = { it.id }) {
Fruit(it)
}
}
By adding key = { fruit -> fruit.id }, we are now telling Compose to track items by their unique ID, not by their position.
With this change, when we remove āAppleā (which has its own unique ID), Compose knows that the composable associated with that specific key is gone forever. It completely removes it, along with its state. It then creates a fresh new composable for āBananaā in its place, with a clean state. Everything works as you would expect! ā
And console also prints valid values meaning LaunchedEffect block is actually working as expected:
Apple has changed
Banana has changed
Orange has changed
Peach has changed
An Alternative (But Flawed) Solution š¤
You might be wondering if there are other solutions. For example, you could pass the fruit object as a key to the remember and LaunchedEffect blocks inside the Fruit composable.
- var quantity by remember { mutableIntStateOf(0) }
+ var quantity by remember(fruit) { mutableIntStateOf(0) }
- var hasChanged by remember { mutableStateOf(false) }
+ var hasChanged by remember(fruit) { mutableStateOf(false) }
//...
- LaunchedEffect(Unit) {/* ... */}
+ LaunchedEffect(fruit) {/* ... */}
This tells Compose to reset the state whenever the fruit input changes, which does fix the issue.
But ideally, a composable shouldnāt have to do this. Why should the Fruit composable worry that it might receive another itemās content? Itās not aware that itās being used in a list. In real-world scenarios, we often read state from a ViewModel, so a situation like this might not happen. But the main takeaway is that if youāre using stateful APIs like remember or side-effects like LaunchedEffect, they will be affected by this recycling behavior.
A component shouldnāt have the overhead of providing extra keys, as it shouldnāt have to worry about where itās going to be rendered. The responsibility should lie with the parent that is managing the list.
Under the Hood: How key Works? š§āāļø
So whatās happening internally? It all comes down to how Compose generates and caches the composables for your list items.
Deep inside the Compose framework, thereās a class called LazyLayoutItemContentFactory. Its main job is to manage the content (the composable lambdas) for each item in the lazy list. It has a cache, which is essentially a map that stores the generated composable for each item.
// From LazyLayoutItemContentFactory.kt
/** Contains the cached lambdas produced by the [itemProvider]. */
private val lambdasCache = mutableScatterMapOf<Any, CachedItemContent>()
When itās time to display an item, the factoryās getContent method is called. This method is the key to our mystery.
// Simplified from LazyLayoutItemContentFactory.kt
fun getContent(index: Int, key: Any, contentType: Any?): @Composable () -> Unit {
val cached = lambdasCache[key] // Tries to find a cached item using the key
return if (cached != null && ...) {
cached.content // Found it! Return the cached composable.
} else {
// Didn't find it. Create a new one and add it to the cache.
val newContent = CachedItemContent(index, key, contentType)
lambdasCache[key] = newContent
newContent.content
}
}
When you provide a key, that key is used to look up the item in lambdasCache. Since your key is stable and unique (like fruit.id), Compose can always find the correct composable along with its remembered state.
But if you donāt provide a key, Compose uses the itemās index as the key. So when āAppleā at index 0 is removed, āBananaā moves to index 0. Compose looks in the cache for index 0 and finds the old composable that belonged to āAppleā, and reuses it for āBananaā.
This cached content is then passed to subcompose, which is the mechanism that actually creates and manages the UI tree for that item.
// From LazyLayoutMeasureScopeImpl.kt
val itemContent = itemContentFactory.getContent(index, key, contentType)
return subcomposeMeasureScope.subcompose(key, itemContent)
By passing your stable key to subcompose, you ensure that the state is correctly associated with the data, not just the position.
Conclusion
So, whatās the main takeaway?
If your list is completely static and will never change, you can get away with not using a key. However, for any list that is dynamic where items can be added, removed, or reordered then providing a key is essential. It not only improves performance but is also crucial for correct state management.
I think we should make it a habit to always add a key. You might think a list is static today, but requirements can change in future and then itās easy to miss it in PR review. Adding a key from the start makes your code more robust and saves you from debugging some very confusing issues down the road.
I hope you got the idea about how important it is to provide key to LazyList APIs.
Awesome. I hope youāve gained some valuable insights from this. If you enjoyed this write-up, please share it š, becauseā¦
āSharing is Caringā
Thank you! š Happy composing! š
Letās catch up on X or visit my site to know more about me š.