---
title: 'Side Effects'
source: 'https://academia.sh/en/courses/compose/side-effects'
course: 'UI Development with Jetpack Compose'
language: en
updated: '2026-10-21T05:02:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines a side effect as a move that leaves the UI tree'
    - 'Counts in the model that an effect restarts when its key changes'
    - 'Measures that both the subscription and the cleanup moves are outside'
    - 'Shows that a service response returns to the tree as a state write and that its direction is up'
---

# Side Effects

While building the tree, a composable function can also touch things outside the tree: it sends a request to a service, writes a log entry, subscribes to a listener. The lesson measures these moves and shows that all of them go in the same direction, outside.

So far data has moved around inside the tree: it went down, it went up, it skipped the nodes in between and went deep. But not everything a composable function does is adding a node to the tree. A composable function can send a request to a service, write a log line, subscribe to a listener of the platform. These are side effects: the trace a function leaves outside the tree. The question of the lesson is again the same: **which direction does this value travel in the UI tree?** The answer for a side effect is always the same.

```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 Move That Leaves the Tree

The platform documentation describes a side effect as a change to the state of the app that happens outside the scope of a composable function, and it reminds us that composable functions can rerun at any moment because of recomposition. For this reason an effect must not be written directly in the body of the function; it has to be wrapped in one of the effect functions the documentation offers. About `LaunchedEffect` the documentation says: "When `LaunchedEffect` enters the Composition, it launches a coroutine with the block of code passed as a parameter." If the key changes, the current coroutine is cancelled and a new one is launched.

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

@Composable
fun CartRow(productId: String) {
    LaunchedEffect(productId) {
        service.loadPrice(productId)
    }
    Text("Loading price")
}
```

As long as `productId` stays the same, recomposition does not restart the effect. If it changes, the previous job is cancelled and a new job starts with the new key. This fragment is platform code, and here we make no claim about output. The model builds this rule of the platform; the model is not the platform itself.

In the model, the effect is defined by its owner node, its target, and its key. Because the target is a name that is not in the tree, every move that leaves the effect is outside.

```kotlin
// Effects.kt — an effect that restarts when its key changes
class Effect(val owner: String, val target: String, private var key: String) {
    fun keyChanged(newKey: String): Boolean {
        val changed = newKey != key
        key = newKey
        return changed
    }
}

fun runIfKeyChanged(effect: Effect, newKey: String, tree: Tree): List<Move> =
    if (effect.keyChanged(newKey)) {
        listOf(Move("request-$newKey", effect.owner, effect.target, tree))
    } else {
        emptyList()
    }

fun contactMove(label: String, effect: Effect, tree: Tree): Move =
    Move(label, effect.owner, effect.target, tree)

fun replyMove(label: String, effect: Effect, tree: Tree): Move =
    Move(label, effect.target, effect.owner, tree)

fun writeToOwner(label: String, effect: Effect, tree: Tree): Move =
    Move(label, effect.owner, tree.ancestors(effect.owner).dropLast(1).last(), tree)

fun writeToParent(label: String, effect: Effect, tree: Tree): Move =
    Move(label, effect.owner, tree.ancestors(effect.owner).first(), tree)
```

These helpers derive the ends of the moves from the `Effect` fields and from the tree. `contactMove` goes from the owner to the target, and `replyMove` goes from the target to the owner. `writeToOwner` picks the screen that holds the state from the tree, and `writeToParent` picks the nearest parent. These functions read the ends of the move from the model's `Effect` record; the outside end is a name that is not in the tree and that the model's record defines. The owner and target names are written in the `Effect(...)` line, but the move is not built by hand from those names.

```kotlin
// Main.kt — four recompositions, two keys, two requests
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val effect = Effect("CartRow", "Service", "")
    val keys = listOf("k1", "k1", "k2", "k2")
    val moves = keys.flatMap { runIfKeyChanged(effect, it, tree) }
    println("recomposition: ${keys.size} | running effect: ${moves.size}")
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
recomposition: 4 | running effect: 2
request-k1: from=CartRow | to=Service -> outside
request-k2: from=CartRow | to=Service -> outside
down: 0
up: 0
deep: 0
outside: 2
```

In four recompositions the key changed only twice: the first key and `k2`. The effect ran twice, two moves came out, and both are outside. Because `Service` is not in the tree, the rule looks at nothing else.

**CM9.** The model builds the difference of the key with text equality. That the platform compares the key with `equals`, the moment the coroutine is cancelled, and the timing of the first run were not measured.

## Subscription and Cleanup

Some effects attach to something and need to detach from it. About `DisposableEffect` the documentation says: the last statement of a `DisposableEffect` block must be `onDispose`; otherwise an error is given at compile time. If the key changes or the composable function leaves the tree, the platform runs `onDispose` first.

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

@Composable
fun CartScreen(logger: Logger) {
    DisposableEffect(logger) {
        logger.subscribe()
        onDispose { logger.unsubscribe() }
    }
}
```

Both subscribing and unsubscribing go outside the tree. Two moves to the same target both go outside; one does not go out while the other comes back.

```kotlin
// Main.kt — subscribe and clean up: two moves, both outside
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "CartList" to "Screen",
    ))
    val effect = Effect("Screen", "Log", "")
    val moves = listOf(
        contactMove("subscribe", effect, tree),
        contactMove("unsubscribe", effect, tree),
    )
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
subscribe: from=Screen | to=Log -> outside
unsubscribe: from=Screen | to=Log -> outside
down: 0
up: 0
deep: 0
outside: 2
```

Although cleanup looks like a return, the direction is not going back. Because the log is not in the tree, "from `Screen`, to `Log`" goes outside; there is no other path the rule could resolve.

The third and fourth effect functions do something different. According to the documentation, `SideEffect` runs after every successful recomposition and is meant for publishing platform state to code outside the platform. `rememberUpdatedState` keeps a value current when you do not want the effect to restart.

```kotlin
// Platform code: SideEffect and rememberUpdatedState
import androidx.compose.runtime.*

@Composable
fun CartRow(onTimeout: () -> Unit, user: User) {
    val currentOnTimeout by rememberUpdatedState(onTimeout)
    LaunchedEffect(true) {
        delay(3000)
        currentOnTimeout()
    }
    SideEffect {
        analytics.setUser(user.id)
    }
}
```

Because the key of `LaunchedEffect(true)` never changes, the effect does not restart; thanks to `rememberUpdatedState`, the callback still carries the most current value. `SideEffect` writes to the `analytics` object outside the tree at every successful recomposition. Both are moves that go outside.

## How the Response Returns to the Tree

When the work the side effect asked for is done, a response comes, and the response has to return to the tree, or nothing on the screen changes. This return is not written directly to a UI node. The effect gives the response to the owner of the state with a state write. There are three moves: request, response, state write.

```kotlin
// Main.kt — the request and the response are outside, the state write is inside the tree
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val effect = Effect("CartRow", "Service", "")
    val moves = listOf(
        contactMove("request", effect, tree),
        replyMove("response", effect, tree),
        writeToOwner("state-write", effect, tree),
    )
    moves.forEach { it.describe() }
    countDirections(moves)
}
```

```text
request: from=CartRow | to=Service -> outside
response: from=Service | to=CartRow -> outside
state-write: from=CartRow | to=Screen -> up
down: 0
up: 1
deep: 0
outside: 2
```

The request and the response go in the same direction, outside. What tells which is which is not the direction but `from` and `to`: in the request the source is in the tree, in the response it is not. This is the direction of the lesson: outside. The third move is inside the tree. The effect writes the response from `CartRow` to the owner, and this is an up; the event path of Lesson 03 connects here.

This lesson differs from M12/K02. That course classifies which ends the network reaches with a request, and the spread. What is asked here is not the spread but whether the move ends inside the tree or outside it.

**CM10.** That the service response returns to the tree with a state write is a rule the model builds. Which thread the coroutine runs on, the timing of cancellation, and the response arriving more than once are not in the model.

## The Bounding Measurement

Two owners, the same network, and the same number of moves. In one, the effect sends two moves from a node in the tree to a target outside. In the other, the effect makes writes that stay inside the tree.

```kotlin
// Main.kt — the effect goes outside, the write stays inside
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val service = Effect("CartRow", "Service", "")
    val log = Effect("CartRow", "Log", "")
    val outside = listOf(
        contactMove("request", service, tree),
        contactMove("log", log, tree),
    )
    val inside = listOf(
        writeToOwner("write-1", service, tree),
        writeToParent("write-2", service, tree),
    )
    println("effect: ${outside.map { it.direction }}")
    println("write: ${inside.map { it.direction }}")
}
```

```text
effect: [outside, outside]
write: [up, up]
```

The same source, two families of targets, two directions. The difference is whether the target is in the tree; which effect function wraps the effect does not change the direction.

## Summary

- This lesson's direction: outside. Every move that leaves an effect goes to a target outside the tree.
- In four recompositions the key changed twice, so the effect ran 2 times and both came out outside.
- Subscribing and cleaning up were measured as two separate moves: outside 2.
- The service response returns to the tree with a state write: outside 2, up 1.
- Two writes that stay in the tree measured up, two effects that leave the tree measured outside; the kind of effect function does not change the direction.

## Next Step

What restarted the effect was the change of the key. How the platform understands whether a value has changed is the basis of skipping in recomposition. The next lesson, `Immutability and Stability`, measures this question and its direction toward the owner.
