LazyColumn Performance Optimization — key, contentType & Recomposition Control
Optimize LazyColumn rendering in Jetpack Compose. Master key parameter for diff updates, contentType for ViewHolder recycling, stability markers, lambda caching, and derivedStateOf for scroll-aware UI. Includes performance checklist.
1. The key Parameter — Why It Matters
Without explicit keys, Compose uses list indices. Indices change when items are added/removed, causing unnecessary recompositions and animation bugs.
// ❌ BAD: No keys — indices shift
LazyColumn {
items(items.size) { index ->
ItemCard(items[index])
}
}
// ✅ GOOD: Unique keys
LazyColumn {
items(
count = items.size,
key = { index -> items[index].id } // Stable, unique key
) { index ->
ItemCard(items[index])
}
}
// ✅ BETTER: Using extension
LazyColumn {
items(
items = items,
key = { it.id } // Each item has unique id
) { item ->
ItemCard(item)
}
}
Impact: Without keys, 100-item list deletion causes 99 unnecessary recompositions. With keys: only 1 recomposition (deletion).
2. contentType for Recycling
ViewHolder-like recycling: group items by type to reuse composition slots.
enum class ItemType { Header, Content, Footer, Ad }
data class ListItem(
val id: String,
val type: ItemType,
val title: String,
val body: String? = null
)
@Composable
fun OptimizedList(items: List<ListItem>) {
LazyColumn {
items(
items = items,
key = { it.id },
contentType = { it.type } // ← Recycling hint
) { item ->
when (item.type) {
ItemType.Header -> HeaderItem(item.title)
ItemType.Content -> ContentItem(item.title, item.body!!)
ItemType.Footer -> FooterItem(item.title)
ItemType.Ad -> AdBanner()
}
}
}
}
Benefit: Compose reuses composition slots for same type, reducing layout thrashing.
3. @stable & @Immutable Markers
Prevent unnecessary recompositions by marking immutable data classes.
@Immutable
data class User(
val id: String,
val name: String,
val avatar: String
)
@Stable
class UserViewModel {
private val _selectedUser = mutableStateOf<User?>(null)
val selectedUser: State<User?> = _selectedUser
}
// Without markers, Compose can't guarantee stability → extra recompositions
// With markers, Compose trusts data hasn't changed
4. Lambda Caching with remember
Callbacks passed to items should be memoized to prevent recompositions.
@Composable
fun UserListScreen(viewModel: UserViewModel) {
// ❌ BAD: New lambda every composition
LazyColumn {
items(viewModel.users) { user ->
UserCard(
user = user,
onDelete = { viewModel.deleteUser(user.id) } // ← New lambda each time
)
}
}
// ✅ GOOD: Memoized callback
val onDeleteCallback = remember {
{ userId: String -> viewModel.deleteUser(userId) }
}
LazyColumn {
items(viewModel.users) { user ->
UserCard(user = user, onDelete = { onDeleteCallback(user.id) })
}
}
}
5. derivedStateOf for Scroll-Aware UI
Detect scroll position changes without triggering full list recompositions.
@Composable
fun ScrollAwareList(items: List<String>) {
val lazyListState = rememberLazyListState()
// ✅ Derived state: only recomposes when result changes
val isScrolled by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex > 0 ||
lazyListState.firstVisibleItemScrollOffset > 0
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Scroll Aware") },
elevation = if (isScrolled) 4.dp else 0.dp // ← Conditional elevation
)
}
) { paddingValues ->
LazyColumn(
state = lazyListState,
modifier = Modifier.padding(paddingValues)
) {
items(items.size, key = { items[it].hashCode() }) { index ->
Text(items[index], modifier = Modifier.padding(16.dp))
}
}
}
}
Without derivedStateOf: Scroll → lazyListState change → full list recomposition.
With derivedStateOf: Scroll → derive Boolean → only TopAppBar recomposes.
6. Performance Checklist
| Issue | Solution | Impact |
|---|---|---|
| No keys | Add key = { it.id }
|
Huge: prevents index-shift recompositions |
| No contentType | Add contentType = { it.type }
|
Medium: improves recycling |
| Mutable data | Use @Immutable/@stable | Medium: helps compiler optimize |
| New lambdas | Memoize with remember
|
Low-Medium: prevents child recompositions |
| Scroll state propagation | Use derivedStateOf
|
High: isolates scroll logic |
| Large lists (>500 items) | Add both keys + contentType | Critical |
| Complex item UI | Extract to separate @Composable | Medium: enables skipping |
| State hoisted wrong | Move state up correctly | High: prevents recomposition leaks |
7. Complete Example: Optimized User List
@Immutable
data class UserItem(val id: String, val name: String, val email: String)
@Composable
fun OptimizedUserList(
users: List<UserItem>,
onUserClick: (String) -> Unit
) {
val lazyListState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf { lazyListState.firstVisibleItemIndex > 0 }
}
val handleClick = remember {
{ userId: String -> onUserClick(userId) }
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Users") },
elevation = if (isScrolled) 4.dp else 0.dp
)
}
) { paddingValues ->
LazyColumn(
state = lazyListState,
modifier = Modifier.padding(paddingValues)
) {
items(
items = users,
key = { it.id },
contentType = { "user" }
) { user ->
UserCard(
user = user,
onClick = { handleClick(user.id) }
)
}
}
}
}
@Composable
fun UserCard(user: UserItem, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(user.name, style = MaterialTheme.typography.bodyLarge)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}
Summary
LazyColumn performance hinges on:
-
key— Prevents index-shift recompositions (critical) -
contentType— Enables ViewHolder-like recycling - @stable/@Immutable — Compiler optimization hints
-
rememberlambdas — Prevent callback churn -
derivedStateOf— Isolate scroll/selection state - Extract composables — Enable skipping
For lists >500 items, use all techniques. For <50 items, keys + contentType suffice.
8 Android app templates: Gumroad
