LazyColumn — what is it, efficient lists in Compose

Author: IT Sectr Published: 2026-02-24 Reading time: 10 min

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

  • LazyColumn — a Jetpack Compose component for vertical lists with lazy loading, part of Material Design 3.
  • LazyListState — list state: scroll position, visible items, programmatic scrolling.
  • items APIitems(), itemsIndexed(), items() functions with keys for data binding.
  • key — a parameter for stable element identification, allowing Compose to reuse composition.
  • LazyVerticalGrid — a version of LazyColumn for grids with a fixed number of columns.

What is LazyColumn in Jetpack Compose?

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.

Difference from Column + ScrollState

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: binding data to the list

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.

kotlin
// 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: managing scroll position

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.

kotlin
// 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 vs RecyclerView: approach comparison

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.

ParameterLazyColumn (Compose)RecyclerView (View)
LayoutComposable functions (Kotlin DSL)XML files + ViewBinding
Adapteritems() / itemsIndexed() DSLRecyclerView.Adapter + ViewHolder
Sorting/FilteringSnapshot state + derivedStateOfDiffUtil + AsyncListDiffer
AnimationAnimatedVisibility + Modifier.animateItemDefaultItemAnimator
Different item typesitem {}/items() + when by typegetItemViewType + multiple ViewHolders
Scroll to positionlistState.animateScrollToItem()layoutManager.scrollToPosition()
PrefetchIn-process (SubcomposeLayout buffer)RecycledViewPool + GapWorker
Network/PaginationcollectAsLazyPagingItems() + Paging 3PagingDataAdapter + 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 and LazyHorizontalGrid: grids

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).

kotlin
// 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.

LazyColumn Performance: key, contentType, prefetch

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.

ParameterDescriptionRecommendation
keyStable element identifierAlways specify key = { it.id }. Without key, Compose uses the index, and item reordering breaks animation.
contentTypeContent type for pool separationSpecify for lists with different item types (text + image + ad). Compose will reuse composition only within the same contentType.
beyondBoundsNumber of prefetch screensIncrease to 2–3 for lists with heavy images. Default is 1 screen.
Modifier.animateItemPosition change animationUse for animated sorting and filtering (Compose 1.7+).
kotlin
// 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

LazyColumn scrolls slowly — what to do?

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.

How to add a divider between LazyColumn items?

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 inside Column doesn't work — why?

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.

How to update LazyColumn when data changes?

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 vs LazyVerticalGrid — which to choose?

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

  • LazyColumn — a Jetpack Compose component for lazy vertical lists with a declarative API.
  • items() API accepts a list and a DSL block for each item; key is mandatory for performance.
  • LazyListState manages scroll position, provides suspend functions for programmatic scrolling.
  • contentType separates composition pools for lists with different item types.
  • LazyVerticalGrid / LazyRow — variants for grids and horizontal lists with the same items API.
  • LazyColumn maintains 60 FPS for 10000+ items thanks to SubcomposeLayout and prefetch buffer.
  • For new Compose projects use LazyColumn; for View-based projects — RecyclerView.

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.

Discuss the project

Read also