How does the Observation framework differ from ObservableObject, and how does tracking work?

8.3.3 · L3 · Interview

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 body or the tracking scope are tracked.
  • Assuming an observation callback keeps firing. withObservationTracking fires once; re-register to continue.

They might follow up with

  • How do you exclude a property? Mark it @ObservationIgnored.
  • What replaces @StateObject? @State holding the @Observable instance, 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…