Lesson 10 / 21
Bindings
Measuring that a child writing a parent's state through a binding is recomputed with the parent trigger, and that the trigger depends on the owner of the value, not on who started the change.
Published Updated
In this lesson
- Distinguishes a binding as a link that opens onto the owner of a value, not a copy of it
- Measures which views are recomputed when a child writes a parent's state
- Shows that a child's own transient state stays local, separate from the parent trigger
- Confirms with two-way writes that the trigger is set by the owner of the value, not by who started the change
Contents
The previous lesson lifted state to an ancestor and gave the child two things: the value itself and a closure for changing it. The combination of these two parts is needed so often that the framework turns it into a single concept: the binding. A binding is not the value itself but a read-and-write path that opens onto the value’s owner. The trigger examined in this lesson is parent: a child view reads and writes a value that is owned by someone else.
The measurement uses the same core file.
// Invalidation.swift — classifies which view a change recomputes and through which source
enum Storage {
case state(owner: String)
case model(object: String)
case environment
}
struct ViewNode {
let name: String
let parent: String?
let reads: Set<String>
init(_ name: String, parent: String?, reads: Set<String>) {
self.name = name
self.parent = parent
self.reads = reads
}
}
struct Recompute {
let view: String
let cause: String
}
struct ViewTree {
let nodes: [ViewNode]
let storage: [String: Storage]
func ancestors(of name: String) -> [String] {
var found: [String] = []
var current = nodes.first { $0.name == name }?.parent
while let next = current {
found.append(next)
current = nodes.first { $0.name == next }?.parent
}
return found
}
func cause(of node: ViewNode, key: String) -> String {
guard let kind = storage[key] else { return "out-of-scope" }
switch kind {
case .environment:
return "environment"
case .model:
return "observed"
case .state(let owner):
if owner == node.name { return "local" }
if ancestors(of: node.name).contains(owner) { return "parent" }
return "off-rule"
}
}
func change(_ label: String, key: String) -> [Recompute] {
let hits = nodes.filter { $0.reads.contains(key) }
.map { Recompute(view: $0.name, cause: cause(of: $0, key: key)) }
let skipped = nodes.filter { !$0.reads.contains(key) }.map(\.name)
let recomputed = hits.isEmpty ? "none" : hits.map { "\($0.view) \($0.cause)" }.joined(separator: ", ")
let rest = skipped.isEmpty ? "none" : skipped.joined(separator: ",")
print("\(label): \(key) changed | recomputed \(recomputed) | skipped \(rest)")
return hits
}
}
func countCauses(_ recomputes: [Recompute]) {
for cause in ["local", "parent", "observed", "environment"] {
print("\(cause): \(recomputes.filter { $0.cause == cause }.count)")
}
}
The core has no separate type for a binding. Storage.state(owner:) says who owns the value, and reads says which view reads it. A view that reads through a binding is still a view that reads; the model builds this rule of the framework’s dependency tracking, and the model is not the framework itself.
A Link to the Owner
Suppose a screen holds a volume level. The level is the screen’s state; the screen gives it to a step button, and the button must be able to raise the level. The child does not own the state; it receives a binding. The parent builds the binding by putting $ in front of the value, and the child declares it with @Binding.
import SwiftUI
struct VolumeScreen: View {
@State private var volume = 3
var body: some View {
VStack {
Text("Volume: \(volume)")
VolumeStepper(volume: $volume)
FooterNote()
}
}
}
struct VolumeStepper: View {
@Binding var volume: Int
@State private var isPressed = false
var body: some View {
Button(isPressed ? "Pressed" : "Raise \(volume)") {
volume += 1
isPressed = true
}
}
}
VolumeStepper has two different values. volume is a binding: the value belongs to the screen, and the child only reads and writes it. isPressed is the child’s own state: it concerns only the button, and nobody elsewhere on the screen knows about it. The distinction from the previous lesson sits side by side here; one value flows along the ancestor–descendant path, and the other stays with its owner.
In the tree, VolumeScreen and VolumeStepper share two values. volume is the screen’s; the screen’s body shows it, and so does the button’s body. FooterNote reads neither. Two changes are applied: one to the binding’s value, one to the child’s transient state.
// main.swift — a binding produces a parent trigger, the child's own state a local one
let storage: [String: Storage] = [
"volume": .state(owner: "VolumeScreen"),
"isPressed": .state(owner: "VolumeStepper"),
]
let screen = ViewTree(nodes: [
ViewNode("VolumeScreen", parent: nil, reads: ["volume"]),
ViewNode("VolumeStepper", parent: "VolumeScreen", reads: ["volume", "isPressed"]),
ViewNode("FooterNote", parent: "VolumeScreen", reads: []),
], storage: storage)
let recomputes = [
screen.change("level-raised", key: "volume"),
screen.change("button-pressed", key: "isPressed"),
].flatMap { $0 }
countCauses(recomputes)
level-raised: volume changed | recomputed VolumeScreen local, VolumeStepper parent | skipped FooterNote button-pressed: isPressed changed | recomputed VolumeStepper local | skipped VolumeScreen,FooterNote local: 2 parent: 1 observed: 0 environment: 0
In the level-raised row, two views were recomputed. VolumeScreen is the owner of the value, so it is local; VolumeStepper is the child that reads the value through a binding, so it is parent. The one who wrote the level was the child, but the child was not the owner. In the button-pressed row, only VolumeStepper was recomputed, and it is local: isPressed belongs to the child, is not tied to the screen through a binding, and the screen is not aware of this change. FooterNote was skipped in both changes.
The numbers come out like this: two changes produced three recomputations; two are local and one is parent. The same child view appeared in two rows with two different triggers: parent because one value arrived through a binding, local because another value is its own. The type of the view does not determine the trigger; the owner of each value that is read does.
SN4. This measurement covers two changes and two keys. The two assignments in the button’s action were not treated as a single change; volume and isPressed were measured separately. Whether the framework merges the two changes in the same cycle was not measured.
A Binding and a Parameter Produce the Same Trigger
It is also possible to give the child the value as a plain parameter instead of a binding. A view that receives a parameter reads the value but cannot write it; to write, it needs a closure, which is the arrangement in the previous lesson. The question is whether the trigger differs between these two forms.
import SwiftUI
struct VolumeScreen: View {
@State private var volume = 3
var body: some View {
VStack {
VolumeStepper(volume: $volume)
VolumeBadge(level: volume)
}
}
}
struct VolumeBadge: View {
let level: Int
var body: some View {
Text("Level \(level)")
}
}
VolumeStepper is the child that can write the value, and VolumeBadge only displays it. Both read the same value. The tree builds the two children together and applies a single change.
// main.swift — a child that reads gets the same trigger by binding or by parameter
let storage: [String: Storage] = [
"volume": .state(owner: "VolumeScreen"),
]
let screen = ViewTree(nodes: [
ViewNode("VolumeScreen", parent: nil, reads: ["volume"]),
ViewNode("VolumeStepper", parent: "VolumeScreen", reads: ["volume"]),
ViewNode("VolumeBadge", parent: "VolumeScreen", reads: ["volume"]),
], storage: storage)
let recomputes = [
screen.change("level-raised", key: "volume"),
].flatMap { $0 }
countCauses(recomputes)
level-raised: volume changed | recomputed VolumeScreen local, VolumeStepper parent, VolumeBadge parent | skipped none local: 1 parent: 2 observed: 0 environment: 0
Three views were recomputed: the screen is local, and the two children are parent. There is no difference in the output between VolumeStepper, which reads through a binding, and VolumeBadge, which reads through a parameter. This is both a finding and a limit. The finding is that reading triggers the recomputation, not the authority to write. The limit is that the model does not separate write authority; ViewNode says only what is read. That a binding gives the child a path to write is a difference at the code level, not at the trigger level.
SN5. This measurement covers the case where both children read the value in the body. The case where a child only writes the binding and never reads it was not measured; in that case it would not appear in reads, so the model would classify it as skipped.
The Bounding Measurement
Who starts a change and who is triggered by it are separate questions. When a child writes through a binding, the first reading that comes to mind is that the trigger belongs to the child. The rule does not say that: the rule does not read the writer. The trigger looks at the owner of the value and at the reader; it does not look at who wrote the change. The measurement below shows this with two changes to the same key: one represents the child writing, the other the parent writing. In the model, the only difference between them is the label.
// main.swift — the rule does not read the writer; the trigger follows the owner of the value
let storage: [String: Storage] = [
"volume": .state(owner: "VolumeScreen"),
]
let screen = ViewTree(nodes: [
ViewNode("VolumeScreen", parent: nil, reads: ["volume"]),
ViewNode("VolumeStepper", parent: "VolumeScreen", reads: ["volume"]),
], storage: storage)
let recomputes = [
screen.change("child-wrote", key: "volume"),
screen.change("parent-wrote", key: "volume"),
].flatMap { $0 }
countCauses(recomputes)
child-wrote: volume changed | recomputed VolumeScreen local, VolumeStepper parent | skipped none parent-wrote: volume changed | recomputed VolumeScreen local, VolumeStepper parent | skipped none local: 2 parent: 2 observed: 0 environment: 0
The two rows are identical: the screen is local, the child is parent. That the writer was the child did not make the child local. The count came out as local 2, parent 2; the writing view did not change any number. This lesson’s trigger is therefore not defined by the child starting the change, but by the child reading a value it does not own.
SN6. This measurement shows an equality at the model level: the same key, the same tree, the same result. Whether the framework orders or groups a write coming from the child differently from a write coming from the parent was not measured.
Summary
- A binding is not a copy of a value but a read-and-write path that opens onto its owner.
- A child that reads a parent’s state through a binding is recomputed with the parent trigger when the value changes; the owner of the state is recomputed with the local trigger.
- The child’s own transient state is independent of the binding and recomputes only the child; the trigger is local.
- A child that reads through a binding and a child that reads through a parameter get the same trigger; the model does not separate write authority.
- The rule does not read the writer: whether the same change comes from the child or the parent, the trigger is set by the owner of the value.
- This lesson’s trigger: parent.
Next Step
So far every value has lived in a view and has been read either in that view or beneath it. In the next lesson, the value lives not in a view but in a separate object, and the views that read it can be anywhere in the tree. The question stays the same, but the trigger goes by a new name: observed.
To keep your progress and take notes, Log in
My notes
Log in to take notes.