Home

Who owns the value

@State, @Binding, and reading a bug backwards to the mistake

Start with the question that decides it: if this view disappeared, should the value go with it?

Yes — @State. The toggle in a settings row, whether a disclosure group is expanded, the text being typed into a search field before it is committed. Nobody else needs it and nothing is lost when the view goes.

No — it belongs to something above, and the view gets a @Binding or reads a model object.

@State is storage, not the value

@State is not “a variable in a struct”. Your struct is thrown away constantly, so the value cannot live there. @State allocates a box in SwiftUI’s own storage and keeps a pointer to it; the struct holds the pointer, the framework holds the box, and the box survives every redraw for as long as the view’s identity does.

Three consequences follow, and each one is a rule people learn by being bitten:

struct Counter: View {
    @State private var count = 0

Always private. The box belongs to this view. A @State that another view can set is a @Binding written wrong, and passing a value into it from a parent does not do what it looks like.

The initial value is used once. It initialises the box the first time this identity appears, and is ignored on every later update. This is the source of the classic bug:

struct Badge: View {
    let colour: Colour
    @State private var current: Colour

    init(colour: Colour) {
        self.colour = colour
        _current = State(initialValue: colour)   // set once, never again
    }
}

Pass a new colour in and current does not change, because the box already exists. If you need state to follow a property, you want onChange(of:), or .id() to deliberately make it a new view — covered in the identity chapter.

Use it for value types. @State holding a class instance stores the reference, so mutating a property of that object changes nothing SwiftUI can see. It never notices, because the pointer it compares is identical. That is what @Observable exists for.

@Binding is a delegated write

A binding is not a copy of a value. It is a pair of closures — a getter and a setter — pointing at storage that lives somewhere else.

struct ParentView: View {
    @State private var isOn = false

    var body: some View {
        ToggleRow(isOn: $isOn)
    }
}

struct ToggleRow: View {
    @Binding var isOn: Bool

    var body: some View {
        Toggle("Notifications", isOn: $isOn)
    }
}

$isOn in the parent projects the @State into a Binding<Bool>. ToggleRow can read and write it without owning it, and without knowing whether the real storage is @State, a property of an @Observable model, or another binding passed down three levels.

Two things worth knowing. A binding can be built from nothing, with .constant(true), which is exactly what previews want. And bindings compose into sub-values — $user.name gives you a Binding<String> writing into the name field of a Binding<User> — which removes most of the plumbing people write by hand.

Warning

Do not create a Binding(get:set:) in body to work around a compile error. It is occasionally the right tool, but it runs your closures on every read, and a setter with a side effect in it will fire at times you did not intend. If you find yourself writing one to “make the types fit”, the ownership is wrong one level up.

When neither fits, it is a model object

Value semantics stop being a help when several unrelated views need to see the same changes — a cart, a player, a sync engine. That is a reference type, marked @Observable, and read directly:

@Observable
final class Cart {
    var items: [Item] = []
    var total: Decimal { items.reduce(0) { $0 + $1.price } }
}

struct CartView: View {
    let cart: Cart          // no wrapper needed to read

    var body: some View {
        List(cart.items) { ItemRow(item: $0) }
    }
}

Note there is no property wrapper on cart. Reading an @Observable from a view is enough for SwiftUI to track it. You only need @Bindable when you want a binding into one — $cart.name for a TextField — and @State when the view itself should own and outlive the object.

Reading a bug backwards

The symptoms are specific enough to diagnose from:

Symptom Usual cause
View does not update at all Mutating a class that is not @Observable, or a plain let property
State resets unexpectedly Identity changed — usually an if with two similar branches
Passing a new value in does nothing @State initialised from a property; the box already existed
Everything redraws on any change State too high in the tree, or a model read wholesale
“Modifying state during view update” Writing state inside body rather than in an action or .task