Skip to content
academia.sh

Lesson 02 / 17

State and Remembering

A composable function keeps nothing from call to call; it ties the value to be kept to a node with remember. The lesson measures that state is tied to a node and the direction of the value that goes from its owner to its reader.

Published Updated

In this lesson

  • Shows that remembered state is tied to a node and is lost when the node leaves the composition
  • Measures that two calls of the same composable function keep separate state
  • Determines with the rule that the value going from the state owner to its reader goes down
  • Explains that local state read only in its own node enters no move
Contents

In the previous lesson the data went down from its owner to its reader through parameters. A parameter is the path a value passes along; where does the value itself live? A composable function runs from the beginning every time it is called, and its local variables disappear when the call ends. The value of a counter, the content of a text field, or whether a list is open has to be carried from call to call. The mechanism that does this is remembering: remember ties a value to a node in the UI tree.

The lesson measures two things. The first is that state is tied to a node. The second is the direction of the value that goes from the owner of the state to its reader: which direction does this value travel in the UI tree? The classifier stands in the first block, as in the previous lessons.

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

State Is Tied to a Node

On the platform, state is built with mutableStateOf, which makes a value observable. remember writes the result of creating this state object into the UI tree and gives back the same object when the composable function is recomposed. If the calling composable function leaves the tree, the remembered object is forgotten too. The platform documentation says it this way: remember stores the value during composition and returns the stored value on recomposition; it forgets the object when the composable function that calls remember is removed from the tree.

// Platform code: Counter
import androidx.compose.runtime.*

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

Counter ties the value count to its own node. Every tap on the button writes the value, and the write asks for the composable functions that read the value to be recomposed. This fragment is platform code, and here we make no claim about output, layout, or duration. 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.

The model carries remembering as a map keyed by the node name. The platform ties state to the position in the tree; in the model, the name of the node takes the place of the position.

// Slots.kt — remembering model that ties state to a node
class Slots {
    private val values = mutableMapOf<String, Int>()
    fun remember(node: String, initial: Int): Int = values.getOrPut(node) { initial }
    fun write(node: String, value: Int) { values[node] = value }
    fun leave(node: String) { values.remove(node) }
    fun holds(node: String): Boolean = node in values
}

remember writes the initial value on the first call and gives back the written value on later calls. write changes the state. leave builds the exit of the node from the tree: the row of the node is deleted.

// Main.kt — state is tied to the node and is lost when the node leaves
fun main() {
    val slots = Slots()
    println("first composition: ${slots.remember("Screen", 0)}")
    slots.write("Screen", 3)
    println("recomposition: ${slots.remember("Screen", 0)}")
    slots.leave("Screen")
    println("left the tree: stored=${slots.holds("Screen")}")
    println("re-entry: ${slots.remember("Screen", 0)}")
}
first composition: 0
recomposition: 3
left the tree: stored=false
re-entry: 0

On recomposition the initial value 0 was given again, but what came back was 3: the remembered value beat the initial value. When the node left the tree and entered again, the state started from scratch. So when a composable function disappears from the screen and comes back, the written value is gone.

CM3. The model builds remembering only with integer values and by node name. That on the platform the key is the position of the call in the tree, that keys given to remember empty the cache, and that observable state marks its readers on a write are not in the model.

Every Call Has Its Own State

If the same composable function is called in two places in the tree, two nodes are created and each node keeps its own state. The cart rows are an example: the same CartRow function is called twice, but increasing the quantity of one does not change the other.

// Main.kt — the same function, two calls, two separate states
fun main() {
    val slots = Slots()
    val rows = listOf("CartRow1", "CartRow2")
    rows.forEach { slots.remember(it, 1) }
    slots.write("CartRow1", 5)
    rows.forEach { println("$it: ${slots.remember(it, 1)}") }
}
CartRow1: 5
CartRow2: 1

State belongs not to the function but to the call of the function in the tree. This distinction gains weight in the next lesson: if two siblings are to share a value, that value cannot stay in the node of one of the siblings.

The Remembered Value Goes Down to Its Reader

The node that remembers the state is its owner. The children take the value from the owner through parameters. The model finds the owner of the state a node reads by walking from the node itself toward its ancestors: the first node that remembers the value is the owner.

// Holder.kt — finds which node remembers the state a node reads
fun holderOf(node: String, slots: Slots, tree: Tree): String? =
    (listOf(node) + tree.ancestors(node)).firstOrNull { slots.holds(it) }

On the cart screen, let Screen remember the quantity, and let Header and CartList read it. One end of the moves comes from the return of holderOf; the direction is not written by hand.

// Main.kt — Screen remembers, children read: two down moves
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Header" to "Screen",
        "CartList" to "Screen",
        "CartRow" to "CartList",
    ))
    val slots = Slots()
    slots.remember("Screen", 2)
    val readers = listOf("Header", "CartList")
    val moves = readers.map { reader ->
        Move("count-${reader.lowercase()}", holderOf(reader, slots, tree)!!, reader, tree)
    }
    moves.forEach { it.describe() }
    countDirections(moves)
}
count-header: from=Screen | to=Header -> down
count-cartlist: from=Screen | to=CartList -> down
down: 2
up: 0
deep: 0
outside: 0

The owner came out as Screen, and the value goes down to its two direct children: two down moves. This is the direction of this lesson. Had CartRow also read the quantity, it would have taken the value through CartList by parameter; as the previous lesson measured, that would be two separate down moves.

This distinction differs from M30/K03. The question of that course, “what triggered this recomputation”, classifies why a view is recomputed from the point of view of the reader: local, parent, observation, environment. Here too a state write asks for the readers to be recomposed, but the lesson does not make that its measure. What we measure is not the trigger but the path of the value from its owner to its reader.

Saveable State

According to the platform documentation, state held with remember is kept across recompositions but is not preserved across configuration changes; rememberSaveable is used for that. rememberSaveable preserves the state with the saved instance state mechanism when the activity or the process is recreated, for example when the screen is rotated. It does not preserve it if the user closes the activity completely.

// Platform code: SavedCounter
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun SavedCounter() {
    var count by rememberSaveable { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

The way of writing it is the same as with remember; the difference is where the value is stored. Values that can be written to a Bundle are saved automatically, and a saver is given for other types. Because our model does not build the environment that the system recreates, this behavior is not measured here; the fragment stays unmeasured.

The Bounding Measurement

The rule classifies a move; it does not classify the absence of one. If the state is read only in its own node, the source and the target are the same, and such a move is not built; Move does not try to classify it, because there is no move. We build the same Counter node in two ways: when it remembers the state itself, and when the state stands in a node above.

// Main.kt — local state is not a move
fun main() {
    val tree = Tree(mapOf(
        "Screen" to "App",
        "Counter" to "Screen",
        "Header" to "Screen",
    ))
    for ((holder, label) in listOf("Counter" to "local", "Screen" to "shared")) {
        val slots = Slots()
        slots.remember(holder, 0)
        val moves = listOf("Counter")
            .filter { holderOf(it, slots, tree) != it }
            .map { Move("count-read", holderOf(it, slots, tree)!!, it, tree) }
        println("state $label:")
        moves.forEach { it.describe() }
        countDirections(moves)
    }
}
state local:
down: 0
up: 0
deep: 0
outside: 0
state shared:
count-read: from=Screen | to=Counter -> down
down: 1
up: 0
deep: 0
outside: 0

With local state the list of moves stays empty and all four directions print zero. When the state moves up to a node above, the same read turns into one down move. The rule does not say that the state “does not exist” while it is local; it only does not count what does not enter the list. This difference gains weight in the next lesson: hoisting the state up is making a local value produce a move.

CM4. The model builds the read move only from the owner of the state to its reader. The reader writing the state, that is, the event in the opposite direction, was not measured in this lesson; it is the subject of the next lesson.

Summary

  • This lesson’s direction: down. The value that goes from the owner of the remembered state to its reader goes down to a direct child.
  • remember ties the value to a node: on recomposition the stored 3 came back, and when the node left the tree and entered again, the value returned to 0.
  • Two calls of the same function keep separate state: 5 for CartRow1, 1 for CartRow2.
  • When two direct children read the quantity that Screen remembers, the measurement gave down 2.
  • Local state read only in its own node produces no move: all four directions are 0; when the state stands in a node above, down 1.

Next Step

In this lesson the value went down from its owner to its reader. What happens when the reader wants to change the value? The next lesson, State Hoisting, moves the state to the common ancestor of the readers and measures which direction the request to change comes back to 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