Jetpack Compose LazyColumn: A Practical Guide to Fast, Stateful Lists

Quick answer: Use
LazyColumnfor vertically scrolling collections whose size is large, unknown, or changes over time. It uses a lazy-list DSL to compose and lay out the content needed for the viewport, rather than laying out every row at once. Give mutable lists stable keys, hoistLazyListStateonly when other UI needs to control the list, and measure real scrolling performance in a release build.
LazyColumn is one of the Compose APIs you will use for feeds, search results, messages, settings screens, and product lists. It is often introduced as the Compose equivalent of RecyclerView, which is a useful mental model—but the important part is understanding how Compose tracks item identity and state.
This guide focuses on the decisions that make a list reliable after users filter it, reorder it, scroll it, or return to it later.
When should you use LazyColumn?
Use a regular Column when the content is short, known in advance, and does not need scrolling. If a Column becomes scrollable, it still composes and lays out all of its children.
Use LazyColumn when the list is vertical and potentially long. According to the official lazy-list documentation, lazy layouts compose and lay out the items needed in the viewport as the user scrolls.
| Requirement | Better choice |
|---|---|
| A few static settings rows | Column |
| A long or unknown-length vertical feed | LazyColumn |
| A horizontal carousel | LazyRow |
| A multi-column collection | LazyVerticalGrid |
| Tags that wrap across lines | FlowRow |
For responsive wrapping content such as tags and filters, see Flow Layouts in Jetpack Compose.
Build a keyed LazyColumn
A LazyColumn uses a LazyListScope DSL. Inside it, item { } adds one entry, while items(...) { } describes a collection.
Here is a small but realistic article feed. Its stable id is used as the key because articles can be inserted, removed, or reordered.
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class Article(
val id: Long,
val title: String,
val summary: String,
)
@Composable
fun ArticleList(
articles: List<Article>,
onArticleClick: (Article) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = articles,
key = { article -> article.id },
) { article ->
ArticleRow(
article = article,
onClick = { onArticleClick(article) },
)
}
}
}
@Composable
private fun ArticleRow(
article: Article,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(16.dp),
) {
Text(
text = article.title,
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = article.summary,
style = MaterialTheme.typography.bodyMedium,
)
}
}contentPadding adds space around the list content itself, while verticalArrangement adds space between items. This is usually clearer than adding outer padding to every row.
Why stable keys matter
Without a key, Compose identifies an item by its position. That is fine for a fixed list that never changes, but becomes risky when you insert a new result at the top, apply sorting, or remove an item.
A stable, unique key lets Compose retain an item’s identity across dataset changes. It helps remembered state move with the correct row and is required for reliable lazy-item animations. The key should be durable—such as a database ID—not the current index.
LazyColumn {
items(
items = articles,
key = { article -> article.id },
) { article ->
ArticleRow(
article = article,
onClick = {},
)
}
}If an item contains local UI state, such as an expanded section or a text field, use rememberSaveable when that state should survive the item leaving the composition or an Activity recreation. This is especially important for stateful rows in a lazy list.
For a deeper explanation of UI state and recomposition, read The Ultimate Guide to State Management in Jetpack Compose.
Add headers, footers, and grouped content
One benefit of the lazy-list DSL is that it can mix individual entries with collections in a single scrolling surface.
LazyColumn {
item {
Text(
text = "Recommended for you",
style = MaterialTheme.typography.headlineSmall,
)
}
items(
items = articles,
key = { it.id },
) { article ->
ArticleRow(article = article, onClick = {})
}
item {
Text(
text = "You reached the end",
modifier = Modifier.padding(16.dp),
)
}
}This pattern is preferable to placing a LazyColumn inside a vertically scrolling Column. A same-direction nested list without a predefined height can throw an IllegalStateException. Put the header, list items, and footer in one parent LazyColumn instead.
Sticky headers for grouped lists
stickyHeader() is currently documented as experimental, so verify the API status in the Compose version your app uses and opt in where required.
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
data class Contact(
val id: Long,
val name: String,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ContactsList(
contactsByInitial: Map<Char, List<Contact>>,
) {
LazyColumn {
contactsByInitial.forEach { (initial, contacts) ->
stickyHeader {
Text(
text = initial.toString(),
style = MaterialTheme.typography.titleSmall,
)
}
items(
items = contacts,
key = { contact -> contact.id },
) { contact ->
Text(text = contact.name)
}
}
}
}Prepare grouping and sorting outside the list when possible—for example, in a ViewModel—so the list rendering code stays focused on UI.
Control and react to scroll state
rememberLazyListState() creates the state object used to observe and control list scrolling. Keep it internal when no other composable needs it. Hoist it to the nearest common parent when a sibling, such as a scroll-to-top button, must read or change the scroll position.
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun ScrollToTopAction(
listState: LazyListState,
) {
val scope = rememberCoroutineScope()
val shouldShowButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = shouldShowButton) {
Button(
onClick = {
scope.launch {
listState.animateScrollToItem(index = 0)
}
},
) {
Text("Back to top")
}
}
}animateScrollToItem() is a suspending function, so it must run inside a coroutine. derivedStateOf is useful here because the button only needs to change when the first visible item crosses the threshold—not for every scroll offset change.
For analytics that should react to scrolling without directly driving UI, use snapshotFlow inside a LaunchedEffect. The official guidance demonstrates this pattern for recording an event after a user scrolls beyond the first item.
Handle mixed item types with contentType
A feed may contain stories, date headers, loading placeholders, and inline promotions. When these item shapes differ, provide contentType so Compose can reuse compositions among items with the same structure.
sealed interface FeedItem {
val id: String
data class DayHeader(
override val id: String,
val label: String,
) : FeedItem
data class Story(
override val id: String,
val title: String,
) : FeedItem
}
LazyColumn {
items(
items = feedItems,
key = { item -> item.id },
contentType = { item ->
when (item) {
is FeedItem.DayHeader -> "header"
is FeedItem.Story -> "story"
}
},
) { item ->
when (item) {
is FeedItem.DayHeader -> Text(item.label)
is FeedItem.Story -> Text(item.title)
}
}
}Do not add contentType by habit to a uniform list. It is most useful when the list contains genuinely different reusable layouts.
LazyColumn performance checklist
LazyColumn is efficient, but it cannot compensate for expensive work inside every row.
- Use stable keys when items can move, be inserted, or be removed.
- Sort, filter, and map data before the
LazyColumnbody, ideally in the ViewModel or another presentation layer. - Avoid emitting many unrelated UI elements inside one
item { }block; they are handled as one lazy item. - Give asynchronously loaded content a realistic initial size or placeholder. Zero-sized rows can cause the layout to compose more items than necessary.
- Avoid nested same-direction scroll containers unless the nested child has a fixed size.
- Use
contentTypefor a list with distinct item structures. - Measure scrolling in a release build with R8 enabled. Debug builds are not a reliable way to judge lazy-layout performance.
For diagnosing unnecessary recomposition around list state, see Jetpack Compose Recomposition: Debug & Optimize Performance Guide.
When LazyColumn is not enough
LazyColumn handles rendering a large vertical collection efficiently, but it does not decide how much remote data your app should load. For a potentially unbounded feed, use Paging alongside your list so data is loaded in pages.
For filtered results, keep filtering logic out of the item lambda and render the prepared UI state. The same approach is used in Dynamic Search in Jetpack Compose.
Final takeaway
Use LazyColumn as your default vertical list when the collection is dynamic or potentially long. Start with a clean items() block, then add stable keys when identity matters, LazyListState when UI must control scrolling, and contentType only for genuinely mixed layouts.
The API stays simple because the most important work happens around it: preparing data, defining item identity, and measuring real user flows. For the latest API details and examples, consult the Android Developers lazy lists and lazy grids documentation.