LazyColumn — a Jetpack Compose component for displaying scrollable lists with lazy loading. Unlike RecyclerView, LazyColumn does not create ViewHolders and does not use XML — all elements are described declaratively through composable functions. We show what LazyColumn is, how LazyListState and items API work, and how to achieve 60 FPS performance. At IT Sectr, we use LazyColumn in all new Compose projects. For comparison with the classic approach, read the article about RecyclerView.
Key Takeaways
items(), itemsIndexed(), items() functions with keys for data binding.LazyColumn is a composable function from the Foundation Compose library that displays a vertical list of items with lazy loading: only items visible on screen or nearby (prefetch zone) are created and composed. LazyColumn appeared in Compose 1.0 (2021) alongside LazyRow (horizontal list) and LazyVerticalGrid (grid).
Lazy loading works through the SubcomposeLayout mechanism: LazyColumn measures available space, requests the visible range from LayoutInfo, and composes only items in that range. Items that scroll off screen leave composition (except for the prefetch buffer). According to Google (Android Performance, 2026), LazyColumn maintains 60 FPS when scrolling through a list of 10000+ items on mid-range devices.
Column with vertical scroll is a normal arrangement of all items one below another with the verticalScroll modifier applied. Column is not lazy: all child elements are composed immediately, even if they are not visible. For a list of 200+ items, Column causes significant lag at startup. LazyColumn composes only visible items + buffer (1 screen forward/backward by default), making it indispensable for long lists.
items API is a set of extension functions for LazyColumn that accept a data list and a DSL descriptor for each item. The main functions are: items(count, key, itemContent) for a fixed count, items(list, key, itemContent) for a list, itemsIndexed(list, key, itemContent) with an index. The key parameter is mandatory for performance — it gives Compose a stable element identifier.
// Basic LazyColumn with itemContent
@Composable
fun ArticleList(articles: List<Article>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header — not lazy, always at the top
item {
Text("Latest articles", style = MaterialTheme.typography.headlineMedium)
}
// Article list with key by id
items(articles, key = { it.id }) { article ->
ArticleCard(
title = article.title,
summary = article.summary,
onClick = { onArticleClick(article.id) }
)
}
// Footer with loading indicator
item {
CircularProgressIndicator(
modifier = Modifier.fillMaxWidth().padding(16.dp)
)
}
}
}
// Custom list item
@Composable
fun ArticleCard(title: String, summary: String, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Text(text = summary, style = MaterialTheme.typography.bodyMedium)
}
}
}
item {} vs items(): use item {} for single items that are always present in the list (header, footer, dividers). Use items() for a dynamic data list. items() accepts an Iterable or Int and a DSL block that is called for each element. Combine item and items in one LazyColumn: the order of calls determines the display order.
LazyListState is an object that stores the list state: current scroll position (firstVisibleItemIndex, firstVisibleItemScrollOffset), visible items (layoutInfo), and programmatic scrolling methods (scrollToItem, animateScrollToItem). LazyListState is created via rememberLazyListState() and passed to LazyColumn through the state parameter.
// LazyColumn with scroll position preservation
@Composable
fun ScrollingList(items: List<Item>) {
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
items(items, key = { it.id }) { item ->
ListItemView(item = item)
}
}
// "Scroll up" button appears after the 10th element
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 10 }
}
AnimatedVisibility(visible = showButton) {
FloatingActionButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp),
onClick = {
coroutineScope.launch {
listState.animateScrollToItem(0)
}
}
) {
Icon(Icons.Default.KeyboardArrowUp, "Scroll to top")
}
}
}
}
// Getting visible items info
@Composable
fun ListDebugInfo(listState: LazyListState) {
val visibleItems = listState.layoutInfo.visibleItemsInfo
val totalItems = listState.layoutInfo.totalItemsCount
val firstItem = visibleItems.firstOrNull()?.index
val lastItem = visibleItems.lastOrNull()?.index
Text("Showing $firstItem–$lastItem of $totalItems")
}
Programmatic scrolling is performed via listState suspend functions: scrollToItem(index, scrollOffset) — instant movement (no animation), animateScrollToItem(index) — with animation. For scrolling from a coroutine, use LaunchedEffect or coroutineScope.launch. Important: do not call suspend functions from composition (inside @Composable) — use lambdas (onClick, LaunchedEffect).
LazyColumn and RecyclerView solve the same problem — efficient display of long lists. The difference is in architecture: RecyclerView uses ViewHolder and Adapter (classic Android View), LazyColumn uses declarative composable functions without XML or ViewHolder. The choice depends on the project's technology stack: Compose vs View-based UI.
| Parameter | LazyColumn (Compose) | RecyclerView (View) |
|---|---|---|
| Layout | Composable functions (Kotlin DSL) | XML files + ViewBinding |
| Adapter | items() / itemsIndexed() DSL | RecyclerView.Adapter + ViewHolder |
| Sorting/Filtering | Snapshot state + derivedStateOf | DiffUtil + AsyncListDiffer |
| Animation | AnimatedVisibility + Modifier.animateItem | DefaultItemAnimator |
| Different item types | item {}/items() + when by type | getItemViewType + multiple ViewHolders |
| Scroll to position | listState.animateScrollToItem() | layoutManager.scrollToPosition() |
| Prefetch | In-process (SubcomposeLayout buffer) | RecycledViewPool + GapWorker |
| Network/Pagination | collectAsLazyPagingItems() + Paging 3 | PagingDataAdapter + Paging 3 |
Google's recommendation (Android Developers, 2026): for new projects on Jetpack Compose, use LazyColumn. For View-based projects, use RecyclerView. LazyColumn requires Compose, which adds about 3–5 MB to the APK — consider this when supporting older devices. If a project already uses the View system, LazyColumn cannot coexist in one screen with XML layout without AndroidView — in such cases, it's easier to stick with RecyclerView.
LazyVerticalGrid is a version of LazyColumn for grids with a fixed number of columns. LazyHorizontalGrid is a horizontal grid with fixed rows. Both components use the same lazy loading principles and the same items API as LazyColumn. The columns parameter determines the number of columns via GridCells.Fixed(N) or GridCells.Adaptive(minSize).
// Grid with adaptive columns
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(photos, key = { it.id }) { photo ->
AsyncImage(
model = photo.url,
contentDescription = photo.title,
modifier = Modifier.aspectRatio(1f)
)
}
}
}
// Horizontal list (LazyRow)
@Composable
fun CategoryCarousel(categories: List<Category>) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(categories, key = { it.id }) { category ->
FilterChip(
selected = category.isSelected,
onClick = { onCategoryClick(category.id) },
label = { Text(category.name) }
)
}
}
}
GridCells.Adaptive automatically calculates the number of columns based on the minimum cell size. For example, GridCells.Adaptive(128.dp) on a 360 dp wide screen places 2 columns, on a 600 dp tablet — 4 columns. GridCells.Fixed(2) always shows exactly 2 columns. Adaptive is preferable for multi-screen adaptation — it automatically adjusts to screen width without media queries.
Performance of LazyColumn depends on three factors: key stability (key), content type (contentType), and prefetch buffer (beyondBounds). By default, LazyColumn buffers 1 screen forward and 1 backward. Google (Android Developers, 2026) recommends configuring these parameters for lists with different item types or complex composition.
| Parameter | Description | Recommendation |
|---|---|---|
| key | Stable element identifier | Always specify key = { it.id }. Without key, Compose uses the index, and item reordering breaks animation. |
| contentType | Content type for pool separation | Specify for lists with different item types (text + image + ad). Compose will reuse composition only within the same contentType. |
| beyondBounds | Number of prefetch screens | Increase to 2–3 for lists with heavy images. Default is 1 screen. |
| Modifier.animateItem | Position change animation | Use for animated sorting and filtering (Compose 1.7+). |
// Optimized LazyColumn with contentType and prefetch
@Composable
fun OptimizedFeed(items: List<FeedItem>) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 2 // prefetch 2 screens
) {
items(
items = items,
key = { it.id },
contentType = { it.type } // division by type
) { item ->
when (item) {
is FeedItem.Post -> PostView(item)
is FeedItem.Ad -> AdView(item)
is FeedItem.Suggested -> SuggestedView(item)
}
}
}
}
// Performance measurement via listState
@Composable
fun ScrollPerformance(listState: LazyListState) {
val scrollInfo = listState.layoutInfo
val visibleCount = scrollInfo.visibleItemsInfo.size
val total = scrollInfo.totalItemsCount
// derivedStateOf — does not recompose if value hasn't changed
val scrollProgress by remember {
derivedStateOf {
if (total > 0) visibleCount.toFloat() / total else 0f
}
}
LinearProgressIndicator(
progress = scrollProgress,
modifier = Modifier.fillMaxWidth().height(2.dp)
)
}
Common mistakes: (1) missing key — elements recompose on any list change; (2) using mutableStateListOf without snapshot flows — changes may not be tracked; (3) calling suspend functions inside itemContent — interrupts composition; (4) no contentType for mixed types — Compose reuses ad composition for text posts, causing visual artifacts. At IT Sectr, we add contentType to all lists with three or more content types.
Frequently Asked Questions
Check three parameters: (1) key — without a stable key, Compose cannot reuse composition, (2) contentType — for lists with different item types, (3) beyondBoundsPageCount — increase to 2 for images. Move heavy computations inside itemContent to remember with a key by data. Use Modifier.drawWithContent instead of Image for simple graphics.
Use LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) for spacing between items. For a visual line divider, add item { Divider() } between items. Automatic divider: items(items, key = { it.id }) { item -> ... }, and between each element insert item { HorizontalDivider() }. For this, use LazyListScope.items(list, key) { /* element */ } and separately LazyListScope.item { Divider() }.
LazyColumn has an infinite height (fillMaxSize) by default. Inside a Column without a fixed height, LazyColumn cannot determine its size. Solution: (1) set a fixed height on LazyColumn: Modifier.height(400.dp), (2) use Modifier.weight(1f) inside Column, (3) do not nest LazyColumn inside a vertically scrollable container — use LazyColumn as the root element with item {} for header and footer.
LazyColumn automatically reacts to State changes. If data is stored in mutableStateListOf or mutableStateOf, Compose recomposes only the changed elements. For lists from ViewModel, use collectAsState() with Flow. When updating the list with key, Compose automatically animates changes (addition, removal, reordering) via Modifier.animateItemPlacement().
LazyColumn — for single-column lists (feed, chat, comments). LazyVerticalGrid — for grids (gallery, catalog, icons). If items should be in one column on phone and two on tablet — use LazyVerticalGrid with GridCells.Adaptive. If a mixed grid is needed (different column counts in one section) — use LazyColumn and insert horizontal LazyRow or nested LazyVerticalGrid inside items.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also