PCSalt
YouTube GitHub
Back to Jetpack Compose
Jetpack Compose · 2 min read

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.

  1. Your First Screen
  2. State Management
  3. Navigation
  4. Lists (this post)
  5. Theming
  6. Forms
  7. Side Effects
  8. Custom Layouts
  9. API Integration
  10. 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

ComponentUse case
LazyColumnVertical scrolling list
LazyRowHorizontal scrolling list
LazyVerticalGridGrid layout
LazyHorizontalGridHorizontal grid
stickyHeaderSection headers

Key practices:

  • Always provide key for stable item identity
  • Use contentType when mixing different item types
  • Pre-compute expensive values in ViewModel
  • Use derivedStateOf for scroll-dependent UI

Next: Theming in Compose — Material3, Dynamic Color & Dark Mode.