---
title: Recomposition
source: 'https://academia.sh/en/courses/compose/recomposition'
course: 'UI Development with Jetpack Compose'
language: en
updated: '2026-10-21T05:02:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains recomposition and the skipping of a composable function whose inputs did not change'
    - 'Separates the recomposed and the skipped nodes of a change in the model'
    - 'Measures that when the read stays at the owner the change descends through every level'
    - 'Shows that when the read is deferred to the bottom-most node the change arrives in a single deep move'
---

# Recomposition

When state changes, the platform reruns the composable functions that read the change and skips those whose inputs did not change. The lesson measures the path a change takes from its owner to its reader and how deferring the read changes the direction.

In the previous lesson the event returned to its owner and the owner wrote the new value. After the write the screen has to be updated, but the whole tree does not have to run again. When state changes, the platform reruns only the composable functions that the change can affect. This is called recomposition. The question of the lesson is again the same: **which direction does this value travel in the UI tree?** Here the data is the new value of the state, or the news that it changed; and its direction depends on where the read happens.

```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 }}")
    }
}
```

## Recomposition and Skipping

According to the platform documentation, when state changes the platform schedules a recomposition: it reruns the composable functions that may have changed and updates the composed tree. Recomposition usually starts when a `State<T>` object changes; the platform runs the composable functions that read this object and the composable functions they call that cannot be skipped. A suitable composable function whose inputs have not changed since the previous composition can be skipped entirely. According to the documentation, stable inputs are compared with `equals` and unstable inputs are compared by reference equality in strong skipping mode; if the inputs are equal to the previous values, the composable function is skipped.

The set of composable functions that a recomposition covers is called the recomposition scope. The composable function that reads the changing state is inside the scope. If a composable function inside the scope passes a changed value to its child, the child enters the scope too; a child called with unchanged inputs is skipped.

On the cart screen, let `CartScreen` remember the total and let the bottom-most `CartRow` show it. First we look at the form where the value is read at its owner and passed down through parameters.

```kotlin
// Platform code: ValueChannel
import androidx.compose.runtime.*

@Composable
fun CartScreen() {
    var total by remember { mutableStateOf(0) }
    Header()
    CartList(total = total)
}

@Composable
fun CartList(total: Int) {
    CartRow(total = total)
}

@Composable
fun CartRow(total: Int) {
    Text("Total: $total")
}
```

`CartScreen` reads the value itself and passes it to `CartList`; `CartList` passes it on to `CartRow` without using it. `Header` is called with unchanged inputs. This fragment is platform code, and here we make no claim about output, layout, the number of recompositions, or duration. The model builds this rule of the platform; the model is not the platform itself.

The model builds recomposition with three functions. `scopeChain` gives the path between two nodes. `recomposedScopes` counts the path from the node where the read happens to the node that uses the value as the scope. `skippedScopes` counts the nodes that are children of the scope but not in the scope as skipped. It also builds the moves of the two channels, `valueMoves` and `deferredMoves`; we will use the second later in this lesson.

```kotlin
// Channels.kt — the two paths a change takes from its owner to the reader
fun scopeChain(from: String, to: String, tree: Tree): List<String> =
    ((listOf(to) + tree.ancestors(to)).takeWhile { it != from } + from).reversed()

fun recomposedScopes(readAt: String, consumer: String, tree: Tree): List<String> =
    scopeChain(readAt, consumer, tree)

fun skippedScopes(recomposed: List<String>, nodes: List<String>, tree: Tree): List<String> =
    nodes.filter { it !in recomposed && tree.parent(it) in recomposed }

fun valueMoves(owner: String, consumer: String, tree: Tree): List<Move> =
    scopeChain(owner, consumer, tree).zipWithNext()
        .map { (from, to) -> Move("change-${to.lowercase()}", from, to, tree) }

fun deferredMoves(owner: String, consumer: String, tree: Tree): List<Move> =
    listOf(Move("change-${consumer.lowercase()}", owner, consumer, tree))
```

```kotlin
// Main.kt — the read is at the owner: the scope is the three nodes on the path, Header is skipped
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val nodes = listOf("App", "Screen", "Header", "CartList", "CartRow")
    val recomposed = recomposedScopes("Screen", "CartRow", tree)
    println("recomposed: ${recomposed.sorted()}")
    println("skipped: ${skippedScopes(recomposed, nodes, tree).sorted()}")
}
```

```text
recomposed: [CartList, CartRow, Screen]
skipped: [Header]
```

Because the read happens at `Screen`, the scope starts at `Screen` and includes `CartList` and `CartRow` along the path the value descends. `Header` is a child of the scope but its inputs did not change: it is skipped. `App` is not affected at all. Note that the model assumes the inputs are stable and equal for skipping; stability is the subject of a later lesson.

**CM7.** The model builds the scope as the path from the node of the read to the node that uses the value. That inline functions are not counted as scopes on the platform, that the scope is determined by the boundaries of composable functions, and how many times a recomposition runs were not measured.

## The Parameter Channel: Two Down

We measure the same change as moves. When the read stays at `Screen`, the new value goes to `CartList` through a parameter and `CartList` passes it to `CartRow`. Every pass is from a parent directly to its child.

```kotlin
// Main.kt — the parameter channel: one down move at every level
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val moves = valueMoves("Screen", "CartRow", tree)
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
change-cartlist: from=Screen | to=CartList -> down
change-cartrow: from=CartList | to=CartRow -> down
down: 2
up: 0
deep: 0
outside: 0
```

There are two down moves. `CartList` in between does not use the value, but because the new value arrives at it, it is recomposed. The place of the read determines the number of moves and the scope.

## Deferring the Read: One Deep

The platform documentation describes deferring a read as a performance technique. If the state is held above and read in a child, the read can be wrapped in a callback; this way the read happens only when it is really needed and where it is needed. According to the documentation, when the value is read only inside the child, the nearest scope becomes the child itself when it changes.

```kotlin
// Platform code: DeferredChannel
import androidx.compose.runtime.*

@Composable
fun CartScreen() {
    var total by remember { mutableStateOf(0) }
    Header()
    CartList(total = { total })
}

@Composable
fun CartList(total: () -> Int) {
    CartRow(total = total)
}

@Composable
fun CartRow(total: () -> Int) {
    Text("Total: ${total()}")
}
```

`CartScreen` no longer reads the value; it passes down a callback that reads it. The read happens inside `CartRow`, when `total()` is called. The callback itself passes through every level as a parameter in the first composition; but what changes is not the callback but the value of the state, and the news of this change reaches the scope of the reader directly. In the model the change move goes from the owner to the reader in a single step.

```kotlin
// Main.kt — the read is deferred: the scope is a single node, the change is a single deep move
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val nodes = listOf("App", "Screen", "Header", "CartList", "CartRow")
    val recomposed = recomposedScopes("CartRow", "CartRow", tree)
    println("recomposed: ${recomposed.sorted()}")
    println("skipped: ${skippedScopes(recomposed, nodes, tree).sorted()}")
    val moves = deferredMoves("Screen", "CartRow", tree)
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
recomposed: [CartRow]
skipped: []
change-cartrow: from=Screen | to=CartRow -> deep
down: 0
up: 0
deep: 1
outside: 0
```

The scope is a single node. `Screen` and `CartList` are not recomposed, because neither of them reads the value. The change reaches `CartRow` from `Screen` in a single move, without stopping at the `CartList` in between: deep. This is the direction of the lesson.

This lesson differs from M30/K03. The question of that course, "what triggered this recalculation", classifies why a view was recalculated, from the reader's point of view. Here it is not the cause but the path of the change from its owner to its reader that is measured.

**CM8.** That in the deferred channel the change goes directly to the scope of the reader is the observation rule of the platform, and it is built this way in the model. How the observation works, that is, how the read is associated with a scope, is not in the model.

## The Bounding Measurement

The same data, the same owner, the same reader, two channels. The rule does not see the channel; it looks only at the positions of the two ends. But the difference in direction comes from the channel, and so does the size of the scope.

```kotlin
// Main.kt — the same data, two channels, two directions
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val byValue = valueMoves("Screen", "CartRow", tree)
    val deferred = deferredMoves("Screen", "CartRow", tree)
    println("parameter: scope=${recomposedScopes("Screen", "CartRow", tree).size} directions=${byValue.map { it.direction }}")
    println("deferred: scope=${recomposedScopes("CartRow", "CartRow", tree).size} directions=${deferred.map { it.direction }}")
}
```

```text
parameter: scope=3 directions=[down, down]
deferred: scope=1 directions=[deep]
```

The parameter channel builds two down moves and a scope of three nodes. The deferred channel builds a single deep move and a scope of a single node. Deep does not mean "better": in a deferred read the content of `CartRow` is read through a callback, and the cost of this is the readability of the code. The lesson does not measure this cost. What it measures is that the same change reaches `CartRow` through two channels with two different directions and two different scopes.

## Summary

- This lesson's direction: deep. When the read is deferred to the bottom-most node, the change reaches the reader from its owner in a single move.
- When the read stays at the owner, the recomposed nodes are `CartList`, `CartRow` and `Screen`; `Header` is skipped.
- In the parameter channel the change descends through every level: down 2, deep 0.
- In the deferred channel the same change goes in a single move: down 0, deep 1.
- The two channels connect the same two ends; the scope drops from 3 nodes to 1 node. The rule does not see the channel; the difference in direction comes from the channel.

## Next Step

In this lesson we assumed that composable functions whose inputs did not change are skipped. Which input counts as unchanged is a separate question and is the subject of lesson 06. Before that, the `Side Effects` lesson looks outside recomposition, at effects that leave a trace outside the tree: what is the direction when the end of the move is not in the tree?
