---
title: 'Composable Functions'
source: 'https://academia.sh/en/courses/compose/composable-functions'
course: 'UI Development with Jetpack Compose'
language: en
updated: '2026-10-21T05:02:37+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Explains that a composable function takes data and adds nodes to the UI tree'
    - 'Determines with a rule whether a value travels down, up, deep, or outside the tree'
    - 'Measures that a value passed through parameters at every level is several down moves'
    - 'Shows that the same provider value counts as down or deep depending on the position of the target'
---

# Composable Functions

A composable function takes data as parameters and adds nodes to the UI tree. Data travels down, up, deep, or outside the tree; the lesson classifies eight moves with a single rule.

In K02 we looked at what the app wants from the platform and measured every request with a single question: how does the platform fulfill this request? This course moves to the UI of the same app. A declarative UI is the return of a function: data goes in, a UI tree comes out. Data keeps moving in that tree, and the moves do not all go the same way. Screen state goes down to a list one level below, a tap in a row goes up to the owner of the state, a theme color skips the levels in between and reaches the composable at the bottom, and a payment request leaves the tree and goes to a service.

For this reason the measure of this course differs from that of K02. We do not look at whether a move received a value; we look at **from where to where** it goes. The question of the course is this: **which direction does this value travel in the UI tree?** There are four answers, and they do not change throughout the course: **down**, **up**, **deep**, and **outside**.

The classifier that makes the measurement is in the helper file below. The same file appears as the first block in every lesson.

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

`Tree` carries the UI tree as a map from child to parent. `Move` carries the label, source, and target of a move; the direction comes from the `direction` property, not from the label, and the property asks its questions in order. If one of the ends is not in the tree, the move is outside. If the target is an ancestor of the source, it is up. If the parent of the target is the source, it is down. If the source is an ancestor of the target but not its direct parent, it is deep. If the two ends are unrelated, it raises an `error`. That last case is an error, not a measurement: a direct move cannot be built between siblings, and neither can a move whose source and target are the same.

## Data Goes In, a Tree Comes Out

A composable function (`@Composable`) takes its parameters and adds nodes to the UI tree at the place where it is called. The UI does not appear as a value the function returns; it appears as the tree formed by the other composable functions called inside the function. A composable function can be called only from another composable function. Every call sets up a link between a parent and a child, and the tree is made of these links.

```kotlin
// Platform code: CartScreen
import androidx.compose.runtime.Composable

@Composable
fun CartScreen(items: List<CartItem>, total: String) {
    Header(title = "Cart")
    CartList(items = items)
    CheckoutButton(total = total)
}
```

`CartScreen` calls three composable functions; `Header`, `CartList`, and `CheckoutButton` are its children. The `items` and `total` parameters go down to the functions called from `CartScreen`. This fragment is platform code, and here we make no claim about output, layout, or duration. What the lesson measures is the parent and child relationship that comes out of this call structure; a model built with plain Kotlin values does the measuring. The model builds this rule of the platform; the model is not the platform itself.

## Eight Moves, Four Directions

The model is a shopping cart screen. The root `App` carries `Screen`; under `Screen` stand `Header`, `CartList`, and `CheckoutButton`, and the child of `CartList` is `CartRow`. The ends of the moves are not chosen by hand; they come from two model functions. `hoistTo` finds the lowest common ancestor of the composable functions that read the state; that is the state owner. `nearestProvider` looks among the ancestors of a node for the first node that provides the given key.

```kotlin
// Main.kt
fun hoistTo(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 } }
}

fun nearestProvider(node: String, key: String, provides: Map<String, Set<String>>, tree: Tree): String? =
    tree.ancestors(node).firstOrNull { key in provides.getOrDefault(it, emptySet()) }

fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
        "CheckoutButton" to "Screen",
    ))
    val owner = hoistTo(listOf("CartList", "CheckoutButton", "Header"), tree)
    val themeSource = nearestProvider("CartRow", "theme", mapOf("App" to setOf("theme")), tree)!!
    println("state owner: $owner")
    println("theme provider: $themeSource")

    val moves = listOf(
        Move("cart-list", owner, "CartList", tree),
        Move("total-amount", owner, "CheckoutButton", tree),
        Move("title", owner, "Header", tree),
        Move("delete-row", "CartRow", owner, tree),
        Move("checkout-tap", "CheckoutButton", owner, tree),
        Move("theme-color", themeSource, "CartRow", tree),
        Move("payment-service", owner, "PaymentApi", tree),
        Move("event-log", owner, "Analytics", tree),
    )
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
state owner: Screen
theme provider: App
cart-list: from=Screen | to=CartList -> down
total-amount: from=Screen | to=CheckoutButton -> down
title: from=Screen | to=Header -> down
delete-row: from=CartRow | to=Screen -> up
checkout-tap: from=CheckoutButton | to=Screen -> up
theme-color: from=App | to=CartRow -> deep
payment-service: from=Screen | to=PaymentApi -> outside
event-log: from=Screen | to=Analytics -> outside
down: 3
up: 2
deep: 1
outside: 2
```

Of the eight moves, three are down, two are up, one is deep, and two are outside. The state owner came out as `Screen`. The cart list, the total amount, and the title go from the owner straight to its children: down. The delete in a row and the tap on the checkout button go to the owner, which is their ancestor: up. The theme color goes from `App` to `CartRow`; `Screen` and `CartList` lie in between and the color does not stop at them: deep. `PaymentApi` and `Analytics` are not in the tree; the payment request and the log line leave the tree: outside. This distribution is the binding count of the course; later lessons measure only their own slice and do not change this count.

**CM1.** The model carries the tree only as a parent relationship. How the real UI tree is built, and in which order the composable functions are called, was not measured.

## The Parameter Chain

If a composable function gives its parameter directly to its child, the move is a single one. `Banner` calling `Greeting(name)` is one move from `Banner` to `Greeting`. The ends of the move come from the model function of the lesson: `parameterChain` orders the nodes from the owner of the value to its reader, using the ancestors in the tree.

```kotlin
// Chain.kt — parameter chain from the owner of a value to its reader
fun parameterChain(owner: String, reader: String, tree: Tree): List<String> =
    ((listOf(reader) + tree.ancestors(reader)).takeWhile { it != owner } + owner).reversed()
```

```kotlin
// Main.kt — Greeting(name) call: a single move from parent to child
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "Banner" to "Header",
        "Greeting" to "Banner",
    ))
    val path = parameterChain("Banner", "Greeting", tree)
    val moves = path.zipWithNext().map { (from, to) -> Move("greeting-name", from, to, tree) }
    println("chain: ${path.joinToString(" > ")}")
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
chain: Banner > Greeting
greeting-name: from=Banner | to=Greeting -> down
down: 1
up: 0
deep: 0
outside: 0
```

The name went straight from parent to child: a single down move. Now let the same name be known at the top of the screen, in `Screen`, and let it need to reach `Greeting`. `Header` and `Banner` do not use the name themselves; they only pass it one level down.

```kotlin
// Main.kt — the same name descends three levels: three separate down moves
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "Banner" to "Header",
        "Greeting" to "Banner",
    ))
    val path = parameterChain("Screen", "Greeting", tree)
    val moves = path.zipWithNext().map { (from, to) -> Move("name-${to.lowercase()}", from, to, tree) }
    println("chain: ${path.joinToString(" > ")}")
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
chain: Screen > Header > Banner > Greeting
name-header: from=Screen | to=Header -> down
name-banner: from=Header | to=Banner -> down
name-greeting: from=Banner | to=Greeting -> down
down: 3
up: 0
deep: 0
outside: 0
```

When a value is passed through parameters at every level, there are three separate down moves; none of them is deep. The parameter chain leaves no skipped level. `Header` and `Banner` in the middle have to carry the name in their signatures even though they do not use it.

## No Moves Between Siblings

On the cart screen, `Header` and `CartList` are siblings. Suppose a value in the header needs to be read by the list. These two ends are unrelated: neither is the ancestor or the child of the other, so the line `Move("x", "Header", "CartList", tree)` falls into the last branch inside `direction` and raises an `error`. That means a move that cannot be built, not a classification. There is no real path between composable functions either: two siblings talk through their common parent. The value first goes up to the common ancestor, that is, it moves up; from there it goes down to the other sibling, that is, it moves down. A single move between siblings splits into two moves, and this split will explain in the next lessons why state stays at the common ancestor.

A line with both ends written by hand, such as `Move("x", "Screen", "Header", tree)`, can be used to show the rule but does not count as a measurement. In this lesson and in the rest of the course, at least one end of every move counted as a measurement comes from a model function of the lesson; lines written to show the rule are named as such in the prose. The direction is never written by hand.

## The Bounding Measurement

The classifier sees the geometry, not the channel. It does not tell apart whether the value is carried by a parameter, by a callback, or by an implicit provider; it looks only at the positions of the two ends in the tree. To see this, take one provider, one channel, and two readers. `App` provides the theme. One of the readers is its direct child `Screen`; the other is `CartRow`, two levels down.

```kotlin
// Main.kt — the same provider, the same channel, two directions
fun nearestProvider(node: String, key: String, provides: Map<String, Set<String>>, tree: Tree): String? =
    tree.ancestors(node).firstOrNull { key in provides.getOrDefault(it, emptySet()) }

fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val provides = mapOf("App" to setOf("theme"))
    val moves = listOf("Screen", "CartRow").map { reader ->
        Move("theme-${reader.lowercase()}", nearestProvider(reader, "theme", provides, tree)!!, reader, tree)
    }
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
theme-screen: from=App | to=Screen -> down
theme-cartrow: from=App | to=CartRow -> deep
down: 1
up: 0
deep: 1
outside: 0
```

The provider is the same, the channel is the same, the value is the same; the result is two directions. `Screen` is a direct child of the provider, and the value goes down to it. `CartRow` is two levels down, and the value goes deep to it without touching the nodes in between. Saying that a provider value is always deep is wrong: the distance of the provider to the target decides the direction. Had we passed the same value from `Screen` to `CartList` and from there to `CartRow` through parameters, there would have been two down moves. Telling the channels apart is not the job of the rule; the lesson says which channel it builds each time.

**CM2.** The model carries the provider as a set of keys and looks only for the nearest provider. A node in between providing the same key again does not occur in any of the eight moves measured in this lesson.

## Summary

- This lesson's direction: down. A composable function takes data through parameters, and every move from a parent straight to a child is down.
- In the core measurement of the course, the distribution of the eight moves is down 3, up 2, deep 1, outside 2; later lessons do not change this count.
- When the same name is passed through parameters across three levels, there are three separate down moves: down 3, deep 0.
- The same provider value goes down to a direct child and deep to a descendant two levels down: down 1, deep 1.
- The rule sees the geometry, not the channel; the lesson says which channel it builds.

## Next Step

In this lesson the data went down from its owner to its reader through parameters. The next lesson, `State and Remembering`, measures how state is remembered across calls of a composable function and which direction a value travels from the owner of the state to its reader.
