How does the Observation framework differ from ObservableObject, and how does tracking work?
Predict first
How many times does the tracking closure’s onChange fire here?
import Observation
@Observable final class M { var a = 0; var b = 0 }
let m = M()
nonisolated(unsafe) var fired = 0
withObservationTracking { _ = m.a } onChange: { fired += 1 }
m.b = 1; print(fired)
m.a = 1; print(fired)
m.a = 2; print(fired)
- 0, 1, 2
- 0, 1, 1
- 1, 2, 3
Predict first
How many objectWillChange notifications do two changes to two different @Published properties produce?
import Combine
final class OO: ObservableObject { @Published var a = 0; @Published var b = 0 }
let oo = OO()
nonisolated(unsafe) var fires = 0
let token = oo.objectWillChange.sink { fires += 1 }
oo.b = 1
oo.a = 1
print(fires)
_ = token
- 0
- 1
- 2
Mental model. ObservableObject rings a bell for the whole building whenever anything changes. Observation sends a message only to the people who asked about that one thing.
With ObservableObject you mark properties @Published, and the object publishes objectWillChange for every change; any view that holds the object (@ObservedObject, @StateObject, @EnvironmentObject) re-evaluates, even if it never read the property that changed. The @Observable macro (iOS 17, the Observation module) works at property granularity. It rewrites stored properties so that reading one inside a tracking scope records it, and writing one notifies only the scopes that read it. SwiftUI wraps body in such a scope, so a view re-runs only for the properties it actually read, with no wrappers on the model properties and no @Published boilerplate. Computed properties are tracked through whatever stored properties they read, and @ObservationIgnored opts a property out.
A withObservationTracking call is one-shot: it fires once on the first change to a tracked property, and you must track again to keep watching (SwiftUI does that on each render). In views you hold the model with @State if the view owns it, pass it as a plain property if it does not, use @Bindable to get bindings, and use @Environment for shared ones. @Observable classes should be @MainActor when they drive UI. The migration advice: prefer @Observable for new code (fewer updates, simpler wrappers) and keep ObservableObject where you must support older systems or use Combine’s objectWillChange. Watch the traps: tracking sees only reads done during the tracking scope, so reading a property in an escaping closure or Task later is not tracked.
Only the property that was read (a) triggers it, and a tracking call is one-shot, so the second change to a does not fire again.
The object publishes for every change, to everyone who observes it, no matter which property they read.
Common traps
- Reading a property in a deferred closure and expecting updates. Only reads during
bodyor the tracking scope are tracked. - Assuming an observation callback keeps firing.
withObservationTrackingfires once; re-register to continue.
They might follow up with
- How do you exclude a property? Mark it
@ObservationIgnored. - What replaces
@StateObject?@Stateholding the@Observableinstance, which survives view updates.
Related questions
- 8.2.1 How does SwiftUI decide to re-run body, and how do you keep updates cheap?
- 8.3.1 What do @State, @Binding and @Bindable do, and who owns the data?
- 8.3.2 How does the SwiftUI environment work, and when should you use it instead of passing a parameter?
← How does the SwiftUI environment work, and when should you use it…