Lesson 09 / 21
Local State
Measuring that a view's own state recomputes only that view, that lifting the same state to an ancestor turns the trigger into parent, and that a sibling's state leaves the read off-rule.
Published Updated
In this lesson
- Relates a view's own state to the local trigger, where the view itself owns the value
- Measures that lifting the same state to an ancestor turns the trigger from local into parent
- Shows that state flows only from its owner to the views below it, never to a sibling
- Derives which views state lifting recomputes from the paths that read the value
Contents
The previous topic completed how views are built: a view was a value described with modifiers and builders. This topic looks at the variables inside that description. The opening lesson showed that six changes produce eight recomputations and that two of them are local; both local rows came from a view’s own value. The trigger examined in this lesson is local: the view that owns the value is the one that experiences the recomputation.
The measurement again uses the core file below. It does not change during the lesson, and every lesson in the course gives it verbatim.
// 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)")
}
}
Storage.state(owner:) says which view a value belongs to. The cause function compares that owner with the view affected by the change: if the owner is the view itself, the trigger is local; if the owner is an ancestor of the view, it is parent; if it is neither, it is off-rule. The model builds this rule of the framework; the model is not the framework itself.
A View’s Own State
A value that changes over time and concerns only one view is kept as local state. The value is marked with a property wrapper, @State. The view below keeps a tap counter. The counter is marked private: it is not exposed outside the view, and only the view’s own body reads and writes it.
import SwiftUI
struct CounterCard: View {
@State private var tapCount = 0
var body: some View {
VStack {
Text("Taps: \(tapCount)")
Button("Increment") { tapCount += 1 }
}
}
}
A value marked @State lives not inside the view value but in a store the framework manages. A view is a struct and can be rebuilt on every recomputation; the counter is not reset by each newly built value, because the store holds the value, not the view. The owner is the view that uses this store through its own identity. In the measurement this distinction becomes the owner field.
In the tree below, DashboardView is a root, CounterCard is its child, and StatusLabel is its second child. The type and property names in the source fragment above appear unchanged in the tree: the key is tapCount, the owner is CounterCard. Which value each view reads is recorded in reads, following the bodies in the source; DashboardView and StatusLabel do not read the counter.
// main.swift — a view's own state recomputes only that view
let storage: [String: Storage] = [
"tapCount": .state(owner: "CounterCard"),
]
let screen = ViewTree(nodes: [
ViewNode("DashboardView", parent: nil, reads: []),
ViewNode("CounterCard", parent: "DashboardView", reads: ["tapCount"]),
ViewNode("StatusLabel", parent: "DashboardView", reads: []),
], storage: storage)
let recomputes = [
screen.change("tapped", key: "tapCount"),
].flatMap { $0 }
countCauses(recomputes)
tapped: tapCount changed | recomputed CounterCard local | skipped DashboardView,StatusLabel local: 1 parent: 0 observed: 0 environment: 0
One change, one recomputation: only CounterCard was recomputed, and the trigger is local. DashboardView and StatusLabel were skipped because neither reads tapCount. This is the same as the row-expanded row in the opening lesson: the value belongs to the view, and the effect of the change stays in that view. The last four lines of the output show the count for the four categories; local is one, and the other three are zero.
This is the payoff of local state: the scope of a change is limited to the owner’s body. A view living elsewhere on the screen does not know about the counter and does not have to.
SN1. This measurement covers a single change and a single key. That the counter value survives every time the view is rebuilt was explained in a sentence but not measured; the model’s ViewTree type does not carry the lifetime of a value.
Lifting State to an Ancestor
If another view also needs to know the counter, local state is not enough. The state has to move to the common ancestor of the interested views; this is called state lifting. The ancestor becomes the owner of the state, hands the value to the child as a parameter, and passes the child a closure for changing it.
import SwiftUI
struct DashboardView: View {
@State private var tapCount = 0
var body: some View {
VStack {
CounterCard(tapCount: tapCount, onTap: { tapCount += 1 })
StatusLabel()
}
}
}
struct CounterCard: View {
let tapCount: Int
let onTap: () -> Void
var body: some View {
VStack {
Text("Taps: \(tapCount)")
Button("Increment", action: onTap)
}
}
}
The view still draws the same screen; what changed is where the value lives. CounterCard no longer stores tapCount; it only shows the value it is given. The owner is now DashboardView, and because its body passes the value to the child, it reads it. Since ownership moved, the tree’s storage table changes too: the same key, a new owner.
// main.swift — when state is lifted, the same key produces a parent trigger
let storage: [String: Storage] = [
"tapCount": .state(owner: "DashboardView"),
]
let screen = ViewTree(nodes: [
ViewNode("DashboardView", parent: nil, reads: ["tapCount"]),
ViewNode("CounterCard", parent: "DashboardView", reads: ["tapCount"]),
ViewNode("StatusLabel", parent: "DashboardView", reads: []),
], storage: storage)
let recomputes = [
screen.change("tapped", key: "tapCount"),
].flatMap { $0 }
countCauses(recomputes)
tapped: tapCount changed | recomputed DashboardView local, CounterCard parent | skipped StatusLabel local: 1 parent: 1 observed: 0 environment: 0
The same change now recomputed two views. DashboardView is local because it owns the value; CounterCard is parent because it is a child view that reads the value without owning it. StatusLabel is still skipped: an ancestor that takes ownership of a value does not recompute every child, only the ones that read it. That is the cost of state lifting: the same tap produces two recomputations instead of one. In return, other views on the screen can read the counter as well.
Comparing the numbers with the earlier measurement, the table reads as follows: local state is one recomputation, one local; state lifted to an ancestor is two recomputations, one local and one parent. What changed is not the value itself but its owner. The same CounterCard was recomputed with the local trigger in the first measurement and the parent trigger in the second; the trigger depends on the owner of the value, not on the type of the view.
SN2. This measurement covers two ownership arrangements: the counter was owned either by CounterCard or by DashboardView. Lifting ownership to an ancestor deeper than the root view, and several children reading the same value, were not measured.
Stay Local or Lift
The two measurements give a decision rule, and the rule looks at who reads the value. If only one view reads the value, the state stays in that view: a change produces a single recomputation and does not touch any other view’s body. If several views read the value, the state moves up to the nearest common ancestor of those views, because the value must have a single source of truth. If two views each keep their own counter, the two counters change independently of each other and the screen shows the same thing in two different ways.
The second measurement has a side finding too: when an ancestor takes ownership of state, the children are recomputed, but only the children that read the value. A child that does not read it, like StatusLabel in the second measurement, is skipped. Keeping the state in the lowest possible common ancestor therefore meets two needs at once: it reaches the views that read the value and leaves the others unaffected.
The Bounding Measurement
State has an owner, and the value flows only from the owner to the views below it. Two sibling views cannot reach each other’s state. In the tree below, the counter belongs to CounterCard, but its sibling ResetButton reads it too: there is no ancestor–descendant path, and the two merely share a common ancestor.
// main.swift — a view that reads a sibling's state stays off-rule
let storage: [String: Storage] = [
"tapCount": .state(owner: "CounterCard"),
]
let screen = ViewTree(nodes: [
ViewNode("DashboardView", parent: nil, reads: []),
ViewNode("CounterCard", parent: "DashboardView", reads: ["tapCount"]),
ViewNode("ResetButton", parent: "DashboardView", reads: ["tapCount"]),
], storage: storage)
let recomputes = [
screen.change("tapped", key: "tapCount"),
].flatMap { $0 }
countCauses(recomputes)
tapped: tapCount changed | recomputed CounterCard local, ResetButton off-rule | skipped DashboardView local: 1 parent: 0 observed: 0 environment: 0
CounterCard was recomputed as local. ResetButton received an off-rule row because it is neither the owner nor a descendant of the owner: the model cannot place this read in any of the four categories. It is not a category, and countCauses does not count this row; at the bottom only CounterCard’s local recomputation appears. The rule says this: state belongs to its owner and to the views below its owner. If a sibling needs to read it, the state is lifted to the common ancestor; the arrangement in the second section of this lesson is the way to do that.
SN3. This measurement covers only one sibling read and only the rule the model builds. How the framework handles code that tries to reach a sibling’s state was not measured.
Summary
- A view’s own state is local state; the owner of the value is that view.
- When local state changes, only the owner view is recomputed, and the trigger is local.
- Lifting state to an ancestor changes the owner: the ancestor becomes local, and the child that reads the value becomes parent.
- Depending on the owner of the value, the same view is recomputed with the local trigger in one measurement and the parent trigger in another.
- State flows only from its owner to the views below it; a sibling’s read stays
off-ruleand is not counted. - This lesson’s trigger: local.
Next Step
When we lifted state to an ancestor, we gave the child a value and a closure; the child could change the value only through that closure. The next lesson does the same job with a single concept: the child is given not the value itself but a link that opens onto the value’s owner. The question stays the same, but who triggers the recomputation changes when the child starts the change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.