Skip to content

SwiftUI: @State under the hood

Published: at 11:15 AM

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 @State misconceptions.

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 init to seed @State does not work as expected after first render. The initialValue is 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 @StateObject all 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 invalidatedDependent nodes marked dirtybody scheduledRe-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.


avatar

Nikita Vasilev

A software engineer with over 8 years of experience in the industry. Writes this blog and builds open source frameworks.


Next Post
Swift Concurrency: Part 4