Introduction
A SwiftUI View is a value type. There is no heap allocation, no reference counter, no object identity. When SwiftUI needs a new description of your UI, it constructs your struct from scratch, calls body, and discards the instance. The struct is ephemeral by design.
And yet, @State persists across these reconstructions. You tap a button, the counter increments, the view rebuilds and the new instance of the struct somehow knows the counter is now 1. How can a value type remember anything when it is destroyed and recreated on every render pass?
The answer is that @State does not store its value in the struct. The struct holds only a thin token - a reference to a node in an external, long-lived graph maintained by the SwiftUI runtime. The value lives there, not here.
The struct is the description. The Attribute Graph is the state. Conflating the two is the root of most
@Statemisconceptions.
This external store is Apple’s Attribute Graph - a directed acyclic graph (internally called AGGraph) that tracks every piece of reactive state, every computed value, and every dependency edge in your view hierarchy. It persists for the lifetime of the view’s identity in the hierarchy, which is not the same as the lifetime of the struct value that describes it.
Table of contents
Open Table of contents
Anatomy of the property wrapper
Let’s strip the magic off @State by expanding it manually. The compiler desugars this:
struct CounterView: View {
@State private var count: Int = 0
}
Into roughly this:
struct CounterView: View {
private var _count: State<Int> = State(initialValue: 0)
private var count: Int {
get { _count.wrappedValue }
nonmutating set { _count.wrappedValue = newValue }
}
}
Two things demand attention here.
InitialValue VS WrappedValue
initialValue is passed once to the State struct’s initializer and is used only when SwiftUI first allocates a node for this piece of state in the Attribute Graph. On every subsequent call to body, the struct is reconstructed (and _count = State(initialValue: 0) is executed again), but SwiftUI ignores this new initialValue entirely. It reads the value from the graph node instead.
wrappedValue is the live accessor - it goes through the graph on both read and write. Reading it registers a dependency; writing it triggers invalidation.
This is why passing a value through a parent’s
initto seed@Statedoes not work as expected after first render. TheinitialValueis inert after the node is created. The graph wins.
The Nonmutating Setter
Notice that the count setter is declared nonmutating. This is the mechanical trick that reconciles Swift’s value-type semantics with observable mutation. The setter does not modify the struct it reaches through the State wrapper into the Attribute Graph node (a reference type inside) and updates the value there. The struct itself is unchanged, which is why Swift allows this on a let-bound view.
The Attribute Graph and DynamicProperty injection
The bridge between the struct’s _count: State<Int> and the actual node in the graph is established through the DynamicProperty protocol.
public protocol DynamicProperty {
mutating func update()
}
This deceptively small protocol is SwiftUI’s hook into the view’s lifecycle. Before SwiftUI evaluates body, it performs a graph walk over all properties of your view struct that conform to DynamicProperty and calls update() on each. For State, this is where the injection happens: the runtime locates (or allocates) the graph node associated with this view’s identity and this property’s location in the type, and wires the _count wrapper to it.
The identity used for node lookup is composite. It is derived from:
Structural identity - the view’s position in the hierarchy (which is stable as long as the view type and its position in the parent’s body do not change), combined with explicit identity if you have provided a .id(_:) modifier.
This is why inserting or removing views conditionaly without explicit identity can cause state to bleed between seemingly unrelated views — if two views occupy the same structural position at different times, SwiftUI maps them to the same graph node.
DynamicProperty.update()is also how@Environment,@EnvironmentObject,@FetchRequest, and@StateObjectall receive their injected values. The protocol is the universal seam.
Encapsulation and the private contract
Apple’s documentation says @State should always be private. This is not stylistic preference. It is a correctness constraint that follows from the ownership model.
State in the Attribute Graph is owned by the view that declares it. Lifetime of the graph node is tied to the lifetime of that view’s identity in the hierarchy. If a parent holds a reference to a child’s @State storage (via the _count backing property), and the child is removed from the hierarchy, the node is deallocated, and the parent’s reference becomes dangling. The private keyword structurally prevents this class of bug.
The init Injection Antipattern
Consider this pattern, which appears on Stack Overflow with alarming frequency:
struct ChildView: View {
@State private var text: String
init(initialText: String) {
// Attempting to seed @State from parent
_text = State(initialValue: initialText)
}
}
struct ParentView: View {
@State private var name = "Alice"
var body: some View {
ChildView(initialText: name)
Button("Change") { name = "Bob" }
}
}
On first render, this works. SwiftUI sees ChildView for the first time, allocates a graph node, and seeds it with the initialValue from State(initialValue: initialText). The text displays “Alice”.
Now the user taps “Change”. name becomes “Bob”. ParentView.body is re-evaluated. ChildView(initialText: "Bob") is constructed. The init runs, creating a new State(initialValue: "Bob"). But SwiftUI recognises ChildView at the same structural position - same identity. The graph node already exists. The initialValue: "Bob" is silently discarded. The text still shows “Alice”.
This is not a bug. This is the system working exactly as designed. If you need the child to respond to parent-driven changes, the correct tool is Binding, not @State with an init seeded from the parent.
Invalidation, dependency tracking, and body re-evaluation
When you write count += 1 inside a button action, the chain of events is precise and worth tracing step by step.
wrappedValue.set(1) → Graph node invalidated → Dependent nodes marked dirty → body scheduled → Re-evaluation
The setter
The nonmutating set on wrappedValue calls into the Attribute Graph’s internal update mechanism. It stores the new value in the graph node and marks that node as dirty - needing recomputation.
Invalidation Propogation
The graph then propagates invalidation along its dependency edges. Every node that declared a dependency on the modified node gets marked dirty in turn. This propagation is lazy and bounded. It does not evaluate anything yet, it only marks the dirty frontier.
Dependency Tracking
Here is where the mechanism is genuinely elegant. SwiftUI does not require you to explicitly declare which state variables your body depends on. Instead, it tracks this automatically through the read path.
When body is evaluated, every access to a wrappedValue is intercepted. The Attribute Graph records: “this body node read from that state node.” This edge persists until the body is next re-evaluated. If body conditionally reads state - say, only reads detailText when isExpanded == true - the graph only records the dependency on detailText when the condition is true. If the view collapses, that edge disappears after the next re-evaluation, and future changes to detailText will not trigger a re-render.
This is why hiding a view with
opacity(0)instead of a conditional is subtly more expensive. The hidden view’s body still evaluates, still tracks dependencies, and still re-evaluates when those dependencies change.
Body Re-evaluation and Diffing
When the run loop flushes pending updates, SwiftUI re-evaluates only the body of views whose graph node is dirty. The output of body is a tree of View values - again, ephemeral structs. SwiftUI then reconciles this new tree against the previous one.
This reconciliation is not a generic tree diff. SwiftUI compares the new view description against the previous one using value equality where possible, and uses structural position plus explicit identity for the rest. If a leaf view’s value is unchanged - Text("Hello") where Text("Hello") was before - SwiftUI elides the redraw for that subtree entirely without touching UIKit or the render server.
The result is a system that is lazy in all the right places: it tracks only the dependencies that exist at this point in time, invalidates only what is necessary, and re-evaluates only the bodies that are dirty. The struct is ephemeral, the graph is the truth.
Final Thoughts
The mechanism is not magic. It is a property wrapper backed by an external graph, injected via a protocol, with dependency tracking through instrumented accessors.
Understanding these internals does not change how you write most SwiftUI code day to day. But it changes how quickly you diagnose the cases where it behaves unexpectedly which, in a production app with complex navigation stacks and conditional view trees, is a skill worth having.
Thanks for reading
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.