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 collectionitemsIndexed(list) { index, item -> }— likeitems, 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)
}
}
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.