LazyColumn & LazyRow

Every RecipeBox screen you've built so far has shown a handful of items on screen at once. But a real recipe collection can easily grow to hundreds of recipes, and a plain Column is the wrong tool once a list gets that large. In this lesson you'll learn why, and meet LazyColumn and LazyRow — Compose's scrollable list layouts that only compose what's actually visible on screen.

The Problem With a Plain Column for Long Lists

A Column composes every single child you give it, immediately, whether or not that child is ever visible on screen. Wrapping it in Modifier.verticalScroll(rememberScrollState()) makes it scrollable, but does nothing to change that — all the work of creating, measuring, and laying out every item still happens up front:

// Don't do this for a long list — every RecipeRow composes immediately
fun BadRecipeList(recipes: List<Recipe>) {
    Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
        recipes.forEach { recipe ->
            RecipeRow(recipe)
        }
    }
}

If recipes holds 500 items, this composes, measures, and lays out 500 RecipeRow composables the moment this screen appears — even though only 6 or 7 of them are ever visible in the viewport at a time. That's wasted CPU, wasted memory, and it gets noticeably slower to scroll as the list grows. This is exactly the problem the old View system solved with RecyclerView: recycle the views that scroll off screen instead of holding every one of them alive.

LazyColumn: Only Composing What's Visible

LazyColumn is Compose's equivalent of RecyclerView — it only composes and lays out the items currently visible in the viewport (plus a small buffer just outside it), and as you scroll, it discards composables that scroll off one edge and composes new ones as they enter from the other edge. You get the recycling behavior of RecyclerView without writing an Adapter, a ViewHolder, or a LayoutManager:

LazyColumn {
    items(recipes) { recipe ->
        RecipeRow(recipe)
    }
}

LazyRow is the identical idea laid out horizontally instead of vertically — same recycling behavior, same DSL, just a different axis.

The items() DSL

Notice that LazyColumn doesn't take composable content as a normal lambda the way Column does. Its content lambda receives a LazyListScope, which exposes a small set of DSL functions for describing what goes in the list:

  • item { } — a single, one-off item (a header, a footer, an empty-state message)
  • items(list) { item -> } — one entry per element of a collection
  • itemsIndexed(list) { index, item -> } — like items, but also hands you the index

You can freely mix these calls in one LazyColumn, in the order you want them to appear:

LazyColumn(
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    item {
        Text(text = "All Recipes", style = MaterialTheme.typography.titleLarge)
    }

    items(recipes) { recipe ->
        RecipeRow(recipe)
    }

    item {
        Text(text = "${recipes.size} recipes total", style = MaterialTheme.typography.bodySmall)
    }
}

key: Stable Identity Across Recompositions

By default, items() identifies each item by its position in the list. That's usually fine — until the list itself changes shape: a recipe gets deleted, the list gets re-sorted, or a new recipe is inserted at the top. When that happens, position-based identity can mix up which composable belongs to which piece of data, and any per-item state (like an "expanded" toggle held with remember inside RecipeRow) can appear to jump to the wrong recipe, because Compose reused the composition slot at that position for different data than before.

The fix is to give each item a stable, unique key — usually the item's own ID — so Compose tracks identity by that key instead of by position:

LazyColumn {
    items(
        items = recipes,
        key = { recipe -> recipe.id }
    ) { recipe ->
        RecipeRow(recipe)
    }
}
Rule of thumb: always pass key when the list can be reordered, filtered, or have items inserted/removed — which, realistically, is almost every list backed by a mutable data source like RecipeBox's recipe collection.

Worked Example: RecipeBox Recipe List Screen

Putting it together — RecipeBox's Recipe model and a RecipeListScreen that renders it as a properly recycled, stably-keyed list:

data class Recipe(
    val id: String,
    val title: String,
    val ingredients: List<String>,
    val steps: List<String>,
    val photoUri: String?,
    val category: String,
    val prepMinutes: Int
)

@Composable
fun RecipeListScreen(recipes: List<Recipe>) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items = recipes, key = { it.id }) { recipe ->
            RecipeRow(recipe = recipe)
        }
    }
}

@Composable
fun RecipeRow(recipe: Recipe) {
    Card(modifier = Modifier.fillMaxWidth()) {
        Row(
            modifier = Modifier.padding(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = recipe.title, style = MaterialTheme.typography.titleMedium)
                Text(text = recipe.category, style = MaterialTheme.typography.bodySmall)
            }
            Text(text = "${recipe.prepMinutes} min", style = MaterialTheme.typography.labelLarge)
        }
    }
}

Scroll through a RecipeBox list of 5 recipes or 5,000 — RecipeRow is only ever composed for the rows actually near the viewport, and the key keeps each row correctly tied to its recipe no matter how the underlying list is filtered or re-sorted.

LazyRow: The Horizontal Equivalent

RecipeBox's Recipe List screen also has a row of category filter chips above the list — Breakfast, Lunch, Dinner, Dessert — that needs to scroll horizontally if there are more categories than fit on screen. That's exactly what LazyRow is for:

@Composable
fun CategoryFilterRow(categories: List<String>, selected: String, onSelect: (String) -> Unit) {
    LazyRow(
        contentPadding = PaddingValues(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(items = categories, key = { it }) { category ->
            FilterChip(
                selected = category == selected,
                onClick = { onSelect(category) },
                label = { Text(category) }
            )
        }
    }
}

Same recycling, same items() DSL, same key — just laid out on the horizontal axis instead of the vertical one.

Note: As throughout this track, this is correct, static Kotlin/Compose code — this website's own project doesn't run a live Android Studio build, so it isn't executing on this page.
Next up: A list is fine for Breakfast, Lunch, Dinner and Dessert one after another — but RecipeBox's grid view of recipe cards needs a different layout. LazyVerticalGrid covers exactly that.