Skip to content
academia.sh

Lesson 05 / 17

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.

Published Updated

In this lesson

  • 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
Contents

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.

// 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.

// 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.

// 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.

// 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)
}
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.

// 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.

// 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)
}
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.

// 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.

// 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)
}
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.

// 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 }}")
}
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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close