The @Observable macro
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.
@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.
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.