---
title: 'State Hoisting'
source: 'https://academia.sh/en/courses/compose/state-hoisting'
course: 'UI Development with Jetpack Compose'
language: en
updated: '2026-10-21T05:02:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains that a stateless composable function takes the value through a parameter and the change request through a callback'
    - 'Finds the common ancestor of the readers as the owner of the state'
    - 'Measures that the event going to the owner goes up and the value coming down from the owner goes down'
    - 'Shows that when state is hoisted higher than needed the number of moves grows while the direction does not change'
---

# State Hoisting

State hoisting moves state to the common ancestor of the composable functions that read and write it; the value goes down and the event goes up. The lesson measures the direction of the event that goes to the owner and how the height of the owner changes the number of moves.

In the previous lesson `Screen` remembered a quantity and its two children read the value. The value went down, but when a reader wanted to change that value there was nothing it could do: the state belonged not to the reader but to its ancestor. In this lesson the request of the reader returns to the owner. Moving state to the common ancestor of the composable functions that read and write it is called state hoisting. The question of the lesson is the same as in the earlier lessons: **which direction does this value travel in the UI tree?**

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

## Value Down, Event Up

The platform documentation recommends hoisting state to the lowest common ancestor of the composable functions that read and write it. The owner of the state offers the consumers the immutable state and the events that will change it. A composable function receives this through two parameters: the `value` to show and the `onValueChange` to call when a change is requested. A function that does not hold the state itself is called stateless; a function that holds the state and gives it to a stateless function is called stateful.

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

@Composable
fun QuantityField(value: Int, onValueChange: (Int) -> Unit) {
    Button(onClick = { onValueChange(value + 1) }) {
        Text("Quantity: $value")
    }
}

@Composable
fun QuantityScreen() {
    var quantity by remember { mutableStateOf(1) }
    QuantityField(value = quantity, onValueChange = { quantity = it })
}
```

`QuantityField` does not hold the state. It takes the value through a parameter and reports the tap through a callback; writing the new value is the job of `QuantityScreen`. The value goes from the parent to the child, and the event goes from the child to its parent. This is called unidirectional data flow: every move goes one way, but two moves go in opposite directions. This fragment is platform code, and here we make no claim about output, layout, or duration. The model builds this rule of the platform; the model is not the platform itself.

This distinction differs from M12/K03. The question of that course, "who can change this result", classifies the owner of the result. In this lesson a model function finds the owner, but the measure is not the owner: the measure is the direction of the move that goes to the owner and the move that leaves the owner.

## The Common Ancestor Becomes the Owner

The model starts with a function that finds the lowest common ancestor of the readers. The name is new in this lesson; it does the same job as `hoistTo` in the opening lesson.

```kotlin
// Owner.kt — the lowest common ancestor of the readers is the owner of the state
fun commonOwner(readers: List<String>, tree: Tree): String {
    val paths = readers.map { listOf(it) + tree.ancestors(it) }
    return paths.first().first { node -> paths.all { node in it } }
}
```

On the cart screen, `CartList` changes the quantity and `CheckoutButton` reads it to show the total. They are siblings. We know from the previous lesson that a direct move cannot be built between siblings; the value they share passes through the common ancestor. A helper builds this split: one up move from the source to the common owner, and one down move from the owner to the target.

```kotlin
// Siblings.kt — splits a request between siblings into two moves through the common ancestor
fun viaOwner(label: String, from: String, to: String, tree: Tree): List<Move> {
    val owner = commonOwner(listOf(from, to), tree)
    return listOf(
        Move("$label-up", from, owner, tree),
        Move("$label-down", owner, to, tree),
    )
}
```

```kotlin
// Main.kt — a move from sibling to sibling is two moves
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
        "CheckoutButton" to "Screen",
    ))
    println("state owner: ${commonOwner(listOf("CartList", "CheckoutButton"), tree)}")
    val moves = viaOwner("quantity", "CartList", "CheckoutButton", tree)
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
state owner: Screen
quantity-up: from=CartList | to=Screen -> up
quantity-down: from=Screen | to=CheckoutButton -> down
down: 1
up: 1
deep: 0
outside: 0
```

The owner came out as `Screen`. The request that goes from `CartList` to `CheckoutButton` first went up to the owner: up. From the owner it went down to the button: down. Up, the direction of this lesson, is the first half of every share between siblings.

**CM5.** The model finds the owner only from the node names of the readers and the writer. That on the platform the logic of writing and reading the owner can also stand outside the tree, for example in a `ViewModel`, depending on whether it is UI logic or business logic, is not in the model.

## The Full Flow

Now we build the whole flow. The owner is chosen with `commonOwner`. A value goes down from the owner to each reader, and an event goes up from the writing node to the owner. The value and the event pass through every level in between with a parameter and a callback; no level is skipped. The helper that does this produces a down path.

```kotlin
// Flow.kt — values descending from the owner to the readers, the event ascending from the writer to the owner
fun downPath(owner: String, node: String, tree: Tree): List<String> =
    ((listOf(node) + tree.ancestors(node)).takeWhile { it != owner } + owner).reversed()

fun flowMoves(owner: String, readers: List<String>, writer: String, tree: Tree): List<Move> {
    val values = readers
        .flatMap { reader -> downPath(owner, reader, tree).zipWithNext() }
        .distinct()
        .map { (from, to) -> Move("value-${to.lowercase()}", from, to, tree) }
    val events = downPath(owner, writer, tree).reversed().zipWithNext()
        .map { (from, to) -> Move("event-${from.lowercase()}", from, to, tree) }
    return values + events
}

fun liftTo(node: String, levels: Int, tree: Tree): String = tree.ancestors(node)[levels - 1]
```

`flowMoves` does not count the same move twice for two readers; `distinct` makes the shared segment of the path a single move. `liftTo` gives the node a few levels above a node and will be used in the next section.

```kotlin
// Main.kt — value down, event up
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
        "CheckoutButton" to "Screen",
    ))
    val readers = listOf("CartList", "CheckoutButton")
    val owner = commonOwner(readers, tree)
    println("state owner: $owner")
    val moves = flowMoves(owner, readers, "CartList", tree)
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
state owner: Screen
value-cartlist: from=Screen | to=CartList -> down
value-checkoutbutton: from=Screen | to=CheckoutButton -> down
event-cartlist: from=CartList | to=Screen -> up
down: 2
up: 1
deep: 0
outside: 0
```

Two values go down from the owner to its children: two down. The tap that changes the quantity goes from `CartList` to the owner: one up. By count, down leads, but the move that hoisting newly brings is the event: while the state was local there was no event, and once it moved to the owner a request that goes up was born. This is why the direction of the lesson is up.

If the state belongs to only one composable function, there is nothing to hoist. If both the reader and the writer are `CartList`, the common ancestor is `CartList` itself and no move is built.

```kotlin
// Main.kt — a single reader: the owner is the reader itself
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "CartList" to "Screen",
        "CheckoutButton" to "Screen",
    ))
    val owner = commonOwner(listOf("CartList"), tree)
    println("state owner: $owner")
    countDirections(flowMoves(owner, listOf("CartList"), "CartList", tree))
}
```

```text
state owner: CartList
down: 0
up: 0
deep: 0
outside: 0
```

Hoisting is needed only when there is sharing. Local state produces no move, as in the previous lesson.

## The Bounding Measurement

What changes if we hoist the state higher than needed, to `App`? The event still goes to the owner; its direction is up. But because the owner has moved away, the value and the event have to pass through every level in between.

```kotlin
// Main.kt — when the same state is hoisted one level higher, the number of moves grows
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
        "CheckoutButton" to "Screen",
    ))
    val readers = listOf("CartList", "CheckoutButton")
    val needed = commonOwner(readers, tree)
    for (owner in listOf(needed, liftTo(needed, 1, tree))) {
        println("state owner: $owner")
        val moves = flowMoves(owner, readers, "CartList", tree)
        countDirections(moves)
        println("total: ${moves.size}")
    }
}
```

```text
state owner: Screen
down: 2
up: 1
deep: 0
outside: 0
total: 3
state owner: App
down: 3
up: 2
deep: 0
outside: 0
total: 5
```

When the owner is `App`, the value goes down to `Screen` and from there to the two readers: three down moves. The event goes from `CartList` to `Screen` and from there to `App`: two up moves. The total rose from three to five. The names of the four categories did not change; `deep` and `outside` are zero again. `Screen` became only a relay: it does not use the value, but it carries it in its signature. This is also the advice of the platform documentation: hoist state to its lowest common ancestor and do not pass it to composable functions that do not need it.

That a move between siblings splits into two moves through the common ancestor, and that the height of the owner changes the number of moves, depend on the same rule: the rule looks only at the positions of the two ends in the tree. The lesson, not the rule, says which path we chose.

**CM6.** The model assumes that the relay nodes pass the value and the event on as they are. That on the platform a composable function in between transforms the value or handles the event itself is not in the model.

## Summary

- This lesson's direction: up. When state is hoisted, a new move is born from the reader to the owner: the change request.
- The owner is the lowest common ancestor of the readers: `Screen` for `CartList` and `CheckoutButton`.
- A request between siblings splits into two moves: up 1, down 1.
- In the full flow two values go down and one event goes up: down 2, up 1.
- When the state is hoisted to `App`, it becomes down 3, up 2; the total number of moves rises from 3 to 5 and the categories stay the same.

## Next Step

In this lesson the event returned to the owner and the owner sent the value down again. Which composable functions will run again when the value changes is a separate question. The next lesson, `Recomposition`, measures which nodes a change reaches from its owner and how deferring the reading of a value changes the direction.
