Introduction
WWDC 2023 brought one of the most significant shifts in how SwiftUI models are written. The @Observable macro, introduced as a part of a new Observation framework, replaces the old ObservableObject approach entirely and it does so in a way that’s both simpler to use and more efficient at runtime.
This article breaks down exactly what happens when you annotate a class with @Observable, from compile-time transformation to fine-grained SwiftUI re-renders.
Table of contents
Open Table of contents
What the Macro Actually Does at Compile Time
@Observable is not just a protocol conformance. It’s a full code transformation. At compile-time, two things happen to your class:
-
Protocol conformance is added. Your class begins conforming to the
Observablemarker protocol. It has no runtime representation and carries no requirements. It simply signals to the compiler that this type participate in the observation system. -
Stored properties are rewritten as computed properties. This is the core trick. Each stored property gets a private backing store (e.g.
_name) annotated with@ObservationIgnored, and the original property becomes a computed one with an instrumented getter and setter.
// What you write:
@Observable class UserModel {
var name: String = ""
}
// What the macro generates (simplified):
class UserModel: Observable {
@ObservationIgnored private var _name: String = ""
private let _$observationRegistrar = ObservationRegistrar()
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
}
The Three Building Blocks of Access Tracking
Once properties are transformed, observation works through three coordinated mechanisms:
access(keyPath:)- called in every getter. It registers that this property was read by the current observer.withMutation(keyPath:)- wraps every setter. It notifies registered observers that this specific property changed.ObservationRegistrar- injected into your class automatically. It maintains the subscriber list and dispatches change notifications.
The key word is specific. Unlike the old @Published + objectWillChange pipeline that fired for any property change on the whole object, this system tracks at the individual key path level.
How SwiftUI Connects to This: withObservationTracking
SwiftUI bridges into the Observation framework via a global function withObservationTracking(_:onChange:). When SwiftUI evaluates your view’s body, it wraps that evaluation in withObservationTracking. During this pass, every access(keyPath:) call is intercepted and recorded. The result is a precise dependency graph: this view depends on these specific properties of these specific objects.
When any of those properties are mutated, and only those, SwiftUI schedules a re-render of the affected view. Nothing else is invalidated.
Ownership and Observation Are Now Separate Concerns
This is arguably the cleanest conceptual improvements in the new model. Previously, ObservableObject and @StateObject served dual process: they both established observation and managed object lifetime. That conflation caused bugs (the classic “use @StateObject not @ObservedObject to avoid re-creation” confusion).
With @Observable, these conserns are cleanly split:
@Stateis now responsible solely for lifecycle management (lifetime management) for both value types and reference types (i.e.,@Observableclasses).@Observableis responsible exclusively for tracking changes. There is no longer a need for@StateObjector@ObservedObject.
Creating Bindings with @Bindable
Because @Observable objects are not property wrappers, there is no automatic $ projected value. To create bindings to their properties for use in TextField, Toggle, and etc, you use the @Bindable wrapper:
struct EditView: View {
@Bindable var model: UserModel
var body: some View {
TextField("Name", text: $model.name)
}
}
@Bindable is lightweight and can also be used as a local variable inside body if you only need a binding in one specific place.
Performance: Fine-Grained Invalidation
The most impactful runtime benefit is granular re-rendering. Under the old model, a single @Published change anywhere on an ObservableObject called objectWillChange.send(), invalidating every view that held a reference to that object regardless of which property those views actually read.
With @Observable the system knows exactly which properties each view accessed. A view that only reads model.name will not re-render when model.score changes.
For models with many properties and large view hierarchies, this difference in render pressure can be substantial.
Summary
@Observable is a clean, well-designed macro that makes the observation system explicit and transparent. At its core, it:
- Rewrites properties to instrument every read and write.
- Uses
ObservationRegistrarto manage subscribers per key path. - Integrates with SwiftUI via
withObservationTrackingfor automatic, precise dependency tracking. - Separates observation from object lifetime management.
- Requires
@Bindableonly when bindings are explicitly needed.
The old ObservableObject approach will continue to work, but for any new code targeting iOS 17+, @Observable is the better default in virtually every situation.
Thanks for reading
If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.