---
title: 'Lazy Lists'
source: 'https://academia.sh/en/courses/compose/lazy-lists'
course: 'UI Development with Jetpack Compose'
language: en
updated: '2026-10-21T05:02:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that a lazy list brings only the rows in the visible range into the tree'
    - "Shows that the scroll position travels up from the list to the state's owner"
    - 'Distinguishes that data sent to an off-screen row stays outside the tree'
    - "Explains that an item key separates a row's identity from its position"
---

# Lazy Lists

Measuring how only visible rows enter the UI tree, how the scroll position travels up to its owner, and how data sent to a row that is not in the tree stays outside.

In the previous lesson all the items were in the tree, and one item could be anchored to another only through the parent. In a list of rows this assumption gets expensive: building a cart of a thousand rows with a thousand nodes means measuring and placing a thousand rows even while five rows are visible on screen. A lazy list does not do this. It creates nodes only for the rows in the visible range; as the user scrolls, the range shifts, the node of a row leaving the screen drops out of the tree, and a new one is built for each row entering.

This directly affects the tree measurement. Data cannot be sent to a node that is not in the tree. This lesson measures how a row's being or not being in the tree changes the direction of the data going to it. In the same lesson, a value that travels the opposite way is also measured: how does the list's scroll position reach the screen that uses the list?

```kotlin
// Direction.kt — classifies which direction a value travels in the UI tree
class Tree(private val parentOf: Map<String, String>) {
    fun contains(node: String): Boolean = node in parentOf.keys || node in parentOf.values
    fun parent(node: String): String? = parentOf[node]
    fun ancestors(node: String): List<String> {
        val chain = mutableListOf<String>()
        var current = parentOf[node]
        while (current != null) {
            chain.add(current)
            current = parentOf[current]
        }
        return chain
    }
}

class Move(val label: String, val from: String, val to: String, tree: Tree) {
    val direction: String = when {
        !tree.contains(from) || !tree.contains(to) -> "outside"
        to in tree.ancestors(from) -> "up"
        tree.parent(to) == from -> "down"
        from in tree.ancestors(to) -> "deep"
        else -> error("unrelated: $from and $to")
    }

    fun describe() {
        println("$label: from=$from | to=$to -> $direction")
    }
}

fun countDirections(moves: List<Move>) {
    for (name in listOf("down", "up", "deep", "outside")) {
        println("$name: ${moves.count { it.direction == name }}")
    }
}
```

## The Lazy List and the Item

`LazyColumn` is the vertical lazy list; its horizontal counterpart is `LazyRow`. Rows are defined with `items`: you give a collection and a composable function for each item. Where column and row layouts measure their children all at once, a lazy list limits measuring to the visible range. The scroll position is held in a state object (`LazyListState`); the composable that creates and remembers this object can read the position.

```kotlin
// Platform code: a lazy list, its items and the scroll state
@Composable
fun CartList(products: List<Product>) {
    val listState = rememberLazyListState()
    LazyColumn(state = listState) {
        items(products, key = { it.id }) { product ->
            ProductRow(product)
        }
    }
    val first = listState.firstVisibleItemIndex
}
```

Here `CartList` owns `listState`: it creates and remembers the state. `LazyColumn` takes this state as a parameter and updates it as the scroll changes. The rows are created in the `items` lambda, so in the tree they are children of `LazyColumn`. `CartList` is the parent of `LazyColumn`.

The model captures this platform rule. The model is not the platform itself. The helper file does two things: from the total number of rows, the index of the first visible row and the number of rows that fit on screen, it derives the visible range; then it builds a UI tree from the rows in that range. The tree carries `LazyList` under `Screen`, and under it only the visible rows. Two functions also supply the ends of the measurements: `rowName` produces the node name of a row, and `scrollOwner` reads the list's parent from the tree. In this lesson the ends of a move are not typed by hand; at least one end comes from these functions.

```kotlin
// Window.kt — the visible rows of a lazy list (the model's calculation)
fun visibleRows(total: Int, firstVisible: Int, viewportRows: Int): List<String> =
    (firstVisible until minOf(total, firstVisible + viewportRows)).map { rowName(it) }

fun rowName(index: Int): String = "Row$index"

fun scrollOwner(tree: Tree): String = tree.parent("LazyList") ?: error("list is the root")

fun listTree(rows: List<String>): Tree {
    val parentOf = mutableMapOf("Screen" to "App", "LazyList" to "Screen")
    for (row in rows) {
        parentOf[row] = "LazyList"
    }
    return Tree(parentOf)
}
```

## The Visible Range

The first measurement takes a collection of a thousand rows. Five rows fit on screen and the list is at the very top. It prints how many rows are in the tree and the direction of the data going to the first visible row.

```kotlin
// Main.kt — the rows in the tree for a thousand-row list
fun main() {
    val rows = visibleRows(1000, 0, 5)
    val tree = listTree(rows)
    println("rows in tree: ${rows.size}")
    println("rows: ${rows.joinToString(",")}")
    val moves = listOf(Move("data-first-row", "LazyList", rows.first(), tree))
    for (move in moves) {
        move.describe()
    }
}
```

```text
rows in tree: 5
rows: Row0,Row1,Row2,Row3,Row4
data-first-row: from=LazyList | to=Row0 -> down
```

Five of the thousand rows are in the tree. The data the list gave the first row went from parent to child, that is, down. This is the lazy-list form of the same move as the column child in the earlier lessons.

**LC1.** The model takes the number of rows that fit on screen as fixed and does not account for row heights. The platform's ability to prepare rows just beyond the visible range ahead of time is also not in the model.

## The Scroll Position Goes Out to the Owner

When the user scrolls, the first visible row changes. The list produces this information: the list, which handles the scroll gesture, writes the new position into the state object. If the state's owner is `Screen` or a composable under it, the position has traveled from the list to its owner. The list is under its owner, that is, the owner is the list's ancestor; the move goes from a child to an ancestor: up.

The measurement below scrolls the list by three rows. The range shifts to `Row3` through `Row7`; `Row0` leaves the tree, and `Row5` through `Row7` are new rows that were not there before. The scroll position goes to `Screen`.

```kotlin
// Main.kt — the range after scrolling and the position's travel out to the owner
fun main() {
    val before = visibleRows(1000, 0, 5)
    val after = visibleRows(1000, 3, 5)
    val tree = listTree(after)
    println("before: ${before.joinToString(",")}")
    println("after: ${after.joinToString(",")}")
    println("first visible index: 3")
    val moves = listOf(Move("scroll-position", "LazyList", scrollOwner(tree), tree))
    for (move in moves) {
        move.describe()
    }
}
```

```text
before: Row0,Row1,Row2,Row3,Row4
after: Row3,Row4,Row5,Row6,Row7
first visible index: 3
scroll-position: from=LazyList | to=Screen -> up
```

The range stayed five rows, but its content changed: `Row0`, `Row1` and `Row2` left the tree; `Row5`, `Row6` and `Row7` entered. The scroll position went up from `LazyList` to `Screen`. This move comes not from the user's finger but from the list's own state; this is how the screen can read information such as "the user is nearing the fifth row".

**LC2.** The model carries the scroll position as a single integer, the index of the first visible row. Whether the platform also keeps the scroll offset within a row separately was not measured in this lesson.

## The Key Is the Identity

The state of a row that drops out of the tree and enters again has to be preserved by which row it is. The `key` given to the `items` call determines the row's identity. If no key is given, the platform derives the identity from the row's position; adding a new item to the start of the list shifts every row's position by one, and position-bound state passes to the wrong row. If a key is given, the state moves with the item. A key must be unique in the list.

This section contains no measurement. Separating identity from position is not a question of direction but of matching: did the same data pass from one row to another, or stay in the same row? Such matching concerns the node's identity, not the tree relationship. The direction, which is this lesson's axis, does not change even if the key changes: data still goes to the row from the list.

## A Row That Is Not in the Tree

After scrolling, `Row0` is not in the tree. The list still holds the data belonging to `Row0`, because the collection is kept outside the list. But there is no node to receive that data. The core classifies every move in which one end is not in the tree as outside.

```kotlin
// Main.kt — the same data to a visible and a non-visible row
fun main() {
    val tree = listTree(visibleRows(1000, 3, 5))
    val moves = listOf(
        Move("data-row-3", "LazyList", rowName(3), tree),
        Move("data-row-7", "LazyList", rowName(7), tree),
        Move("data-row-0", "LazyList", rowName(0), tree),
    )
    for (move in moves) {
        move.describe()
    }
    countDirections(moves)
}
```

```text
data-row-3: from=LazyList | to=Row3 -> down
data-row-7: from=LazyList | to=Row7 -> down
data-row-0: from=LazyList | to=Row0 -> outside
down: 2
up: 0
deep: 0
outside: 1
```

Data of the same shape from the same source went down to the two visible rows and went outside to the row that is not in the tree. What changes the direction is not the data's content or source but whether the target is in the tree. That is why it is not possible to read the state of an off-screen row or to write a change straight to it. Everything about that row is given again from the collection the list holds when the row re-enters the tree.

**LC3.** The model does not build what happens to the state of a row that drops out of the tree. Whether the values the row itself remembers are lost when it drops out, and how savable state changes this, were not measured in this lesson.

## The Bounding Measurement

Which of the data at the two ends of the list is specific to the lesson's subject decides the lesson's direction. Item data going to a visible row is the same as the column child in the earlier lessons: down. Two things are specific to the lazy list: data going to a row that is not in the tree staying outside, and the scroll position going out to its owner. The measurement below brings them together.

```kotlin
// Main.kt — the direction tally of the list's four moves
fun main() {
    val rows = visibleRows(1000, 3, 5)
    val tree = listTree(rows)
    val moves = listOf(
        Move("data-first-row", "LazyList", rows.first(), tree),
        Move("data-last-row", "LazyList", rows.last(), tree),
        Move("data-invisible", "LazyList", rowName(0), tree),
        Move("scroll-position", "LazyList", scrollOwner(tree), tree),
    )
    for (move in moves) {
        move.describe()
    }
    countDirections(moves)
}
```

```text
data-first-row: from=LazyList | to=Row3 -> down
data-last-row: from=LazyList | to=Row7 -> down
data-invisible: from=LazyList | to=Row0 -> outside
scroll-position: from=LazyList | to=Screen -> up
down: 2
up: 1
deep: 0
outside: 1
```

Two down, one up, one outside. The two moves that go down are the ordinary data a parent gives its child. The outside move is the price of the list being lazy. The up move is the information the lazy list carries for the screen: the position. The list works anyway to give data to the rows; for the screen to be able to follow the list, the position has to go up. The lesson's direction: **up**.

**LC4.** The model sets up four moves for a single scroll position. As the position changes, the move counts change too; this measurement holds only when `firstVisible` is 3.

## Summary

- This lesson's direction: up. The move specific to a lazy list is the scroll position going from the list up to the owner of the state.
- A lazy list creates nodes only for the rows in the visible range; the number of rows in the tree is smaller than the total.
- The data the list gives a visible row goes down.
- The scroll position goes from the list to the state's owner, that is, an ancestor: up.
- Data going to a row that is not in the tree is outside; it waits until the row enters the tree.

## Next Step

A lazy list creates its rows itself, but a composable determines the content of a row. Prebuilt UI components provide this content with ready-made parts, and these parts give their content their own colors. The next lesson measures the direction of the color a button gives its content.
