The @Observable macroswift-6.4/ios-26
Lesson 1 / 4
Unit 8 · Observation & state

The @Observable macro

Coming from
Not covered in this lesson — showing TypeScript.
Note

Unit 8 · Observation & state. Builds on Unit 7's @State/@Binding for value types and sets up the MVVM-lite pattern you'll formalize in Unit 13.

Unit 7 gave you @State for a view's own value-typed state. But real screens have state that outlives a single view and is shared by several: a form's draft, a screen's loading status, a cart. For that you want a reference type that SwiftUI watches for changes. The @Observable macro is how you build one — attach it to a class and SwiftUI re-renders exactly the views that read a property when that property changes.

Declaring an observable model

import Observation

@Observable
final class CounterModel {
    var count = 0

    func increment() { count += 1 }
}

That's the whole thing. The @Observable macro rewrites the class at compile time so every stored property is tracked: when count changes, any view that read count during its last render is invalidated and redrawn. You write a plain class with plain properties — no property wrappers on the fields, no protocol to conform to, no objectWillChange to fire.

Owning it in a view

A view that creates and owns an observable model holds it in @State:

struct CounterView: View {
    @State private var model = CounterModel()

    var body: some View {
        VStack {
            Text("Count: \(model.count)")
            Button("Add") { model.increment() }
        }
    }
}

@State here does two jobs: it keeps the single CounterModel instance alive across re-renders (SwiftUI recreates the struct view constantly, but the model persists), and it subscribes the view to the model's observation. Reading model.count in body is what enrolls this view for updates to count — and only count.

Tip

@State owns; it doesn't have to be a value. People learn @State for Ints and Bools and assume it's only for values. With @Observable, @State is also how a view declares ownership of a reference-typed model it instantiates. The rule is about lifecycle, not type: "this view is the source of truth for this thing."

Binding into a model with @Bindable

@State gives you read access and lets you call methods. But a TextField or Toggle needs a two-way Binding to a property. To project one out of an observable model, use @Bindable:

@Observable
final class ProfileModel {
    var name = ""
    var notificationsOn = false
}

struct ProfileForm: View {
    @Bindable var model: ProfileModel

    var body: some View {
        Form {
            TextField("Name", text: $model.name)
            Toggle("Notifications", isOn: $model.notificationsOn)
        }
    }
}

The $model.name syntax works because @Bindable supplies the $-projection that turns a property of an observable object into a Binding. Use @Bindable when a view receives a model from elsewhere and needs to bind controls to it; use @State when the view creates and owns the model. (Inside a view that already holds the model in @State, you can also write @Bindable var model = model locally to get bindings.)

Why it has to be a class

Observation depends on reference semantics. SwiftUI has to hand the same instance to every view that shares the model and be notified when that instance mutates. A struct can't do this: passing it around makes independent copies (Unit 1), so a mutation in one place would be invisible everywhere else. @Observable therefore only applies to classes — the one place in SwiftUI where you deliberately choose reference semantics over Swift's value-type default.

Coming from TypeScript

If you've used a store from Zustand, Redux Toolkit, or MobX, @Observable is the same idea with none of the boilerplate: a mutable object, and components re-render when the fields they read change. There's no reducer, no action, no selector — you read model.count and mutate model.count, and the framework does the dependency tracking for you.

Knowledge check

Q: Why must an @Observable type be a class, not a struct? Observation needs one shared instance that many views reference and watch for mutation. Structs have value semantics — passing one copies it — so a struct model couldn't be shared or observed.

Q: When do you reach for @Bindable instead of @State? @State when the view creates and owns the model. @Bindable when the view is handed an existing model and needs two-way Bindings (e.g. $model.name) for controls like TextField.