Lists in Compose — LazyColumn, LazyGrid & Performance Tips
Build efficient lists in Jetpack Compose — LazyColumn, LazyRow, LazyGrid, item keys, content types, and performance optimization for smooth scrolling.
This is Part 4 of a 10-part series on Jetpack Compose.
- Your First Screen
- State Management
- Navigation
- Lists (this post)
- Theming
- Forms
- Side Effects
- Custom Layouts
- API Integration
- Migration from XML
LazyColumn — The RecyclerView replacement
LazyColumn only composes items that are visible on screen. Off-screen items are disposed and recomposed as you scroll — like RecyclerView’s view recycling, but without adapters or view holders.
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@Composable
fun UserList(users: List<User>) {
LazyColumn {
items(users) { user ->
UserItem(user)
}
}
}
@Composable
fun UserItem(user: User) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = user.name,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
No adapter class. No onCreateViewHolder. No onBindViewHolder. Just a composable per item.
Item keys — Critical for performance
By default, Compose identifies items by their position index. If the list reorders, every item recomposes. Fix this with key:
LazyColumn {
items(users, key = { it.id }) { user ->
UserItem(user)
}
}
With key, Compose tracks items by their ID. When the list reorders:
- Without key: all items recompose (position changed)
- With key: only moved items update their position
Always provide a key for any list that can change. Use a stable, unique identifier (database ID, UUID).
Headers, footers, and mixed content
LazyColumn {
item {
Text(
text = "Team Members",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp)
)
}
items(activeUsers, key = { it.id }) { user ->
UserItem(user)
}
item {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
}
item {
Text(
text = "Inactive Members",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
}
items(inactiveUsers, key = { it.id }) { user ->
UserItem(user, dimmed = true)
}
item {
Spacer(modifier = Modifier.height(80.dp)) // bottom padding for FAB
}
}
item { } adds a single item. items(list) { } adds a list. Mix them freely.
LazyRow — Horizontal scrolling
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories, key = { it.id }) { category ->
CategoryChip(category)
}
}
@Composable
fun CategoryChip(category: Category) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = category.name,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.labelMedium
)
}
}
contentPadding adds padding to the content without clipping items. Arrangement.spacedBy adds spacing between items.
LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.items
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
items(photos, key = { it.id }) { photo ->
PhotoItem(photo)
}
}
}
@Composable
fun PhotoItem(photo: Photo) {
AsyncImage(
model = photo.url,
contentDescription = photo.description,
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Crop
)
}
Adaptive columns
// Minimum 120dp per column — number of columns adapts to screen width
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 120.dp)) {
items(products, key = { it.id }) { product ->
ProductCard(product)
}
}
GridCells.Adaptive calculates columns based on available width. On a phone, 3 columns. On a tablet, 5 or 6. No manual calculation.
Sticky headers
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@Composable
fun GroupedContactList(contacts: Map<Char, List<Contact>>) {
LazyColumn {
contacts.forEach { (letter, contactsForLetter) ->
stickyHeader {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = letter.toString(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleSmall
)
}
}
items(contactsForLetter, key = { it.id }) { contact ->
ContactItem(contact)
}
}
}
}
stickyHeader stays pinned at the top while scrolling through its section.
Pull to refresh
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@Composable
fun RefreshableList(
users: List<User>,
isRefreshing: Boolean,
onRefresh: () -> Unit
) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh
) {
LazyColumn {
items(users, key = { it.id }) { user ->
UserItem(user)
}
}
}
}
Scroll state and position
import androidx.compose.foundation.lazy.rememberLazyListState
@Composable
fun ScrollableList(items: List<Item>) {
val listState = rememberLazyListState()
// Scroll to top button
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 5 }
}
Box {
LazyColumn(state = listState) {
items(items, key = { it.id }) { item ->
ItemRow(item)
}
}
if (showScrollToTop) {
FloatingActionButton(
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Icon(Icons.Default.KeyboardArrowUp, "Scroll to top")
}
}
}
}
rememberLazyListState() gives you scroll position, visible items, and scroll control.
Performance tips
1. Always use keys
Already covered, but worth repeating. Without keys, list mutations cause excessive recomposition.
2. Use contentType for different item types
LazyColumn {
item(contentType = "header") {
HeaderItem()
}
items(users, key = { it.id }, contentType = { "user" }) { user ->
UserItem(user)
}
items(ads, key = { it.id }, contentType = { "ad" }) { ad ->
AdItem(ad)
}
}
contentType helps Compose reuse compositions more efficiently — it won’t try to reuse a “user” composition for an “ad” item.
3. Avoid heavy computation in item composables
// Bad — runs formatDate on every recomposition
items(posts) { post ->
Text(formatDate(post.timestamp)) // expensive, runs every recompose
}
// Better — compute once, pass the result
items(posts) { post ->
Text(post.formattedDate) // pre-computed in ViewModel
}
4. Use derivedStateOf for scroll-dependent UI
// Bad — recomposes on every scroll pixel
val isAtTop = listState.firstVisibleItemIndex == 0
// Good — only recomposes when the value changes
val isAtTop by remember {
derivedStateOf { listState.firstVisibleItemIndex == 0 }
}
5. Don’t nest scrollable layouts in the same direction
// This crashes — LazyColumn inside a Column with verticalScroll
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
LazyColumn { /* CRASH: nested same-direction scroll */ }
}
// Fix — use item { } inside LazyColumn for non-lazy content
LazyColumn {
item { HeaderContent() }
items(data) { DataItem(it) }
item { FooterContent() }
}
Summary
| Component | Use case |
|---|---|
LazyColumn | Vertical scrolling list |
LazyRow | Horizontal scrolling list |
LazyVerticalGrid | Grid layout |
LazyHorizontalGrid | Horizontal grid |
stickyHeader | Section headers |
Key practices:
- Always provide
keyfor stable item identity - Use
contentTypewhen mixing different item types - Pre-compute expensive values in ViewModel
- Use
derivedStateOffor scroll-dependent UI
Next: Theming in Compose — Material3, Dynamic Color & Dark Mode.