What exactly does Observation track: computed properties, ignored properties, collections, nested objects and equal writes?

8.10.3 · L3 · Interview

Predict first

Which of these changes notify a tracking scope?

import Observation

@Observable final class Cart {
    var items: [String] = ["a"]
    var discount = 0
    @ObservationIgnored var hits = 0
    var count: Int { items.count }
    var label: String { "\(count) items" }
    var note = Note()
}
@Observable final class Note { var text = "x" }
final class Plain { var text = "x" }
@Observable final class Holder { var plain = Plain() }

func fires(_ read: () -> Void, then change: () -> Void) -> Bool {
    nonisolated(unsafe) var fired = false
    withObservationTracking(read, onChange: { fired = true })
    change()
    return fired
}
let c = Cart()
print("read count, append item:", fires({ _ = c.count }, then: { c.items.append("b") }))
print("read count, change discount:", fires({ _ = c.count }, then: { c.discount = 5 }))
print("read computed label, append:", fires({ _ = c.label }, then: { c.items.append("c") }))
print("read items, change @ObservationIgnored:", fires({ _ = c.items }, then: { c.hits += 1 }))
print("read items.count, replace an element:", fires({ _ = c.items.count }, then: { c.items[0] = "z" }))
let later = { _ = c.discount }
print("property read only inside a stored closure:", fires({ _ = later }, then: { c.discount = 9 }))
print("assign the same value:", fires({ _ = c.discount }, then: { c.discount = c.discount }))
print("nested @Observable, edit it:", fires({ _ = c.note.text }, then: { c.note.text = "y" }))
let h = Holder()
print("plain class inside, edit it:", fires({ _ = h.plain.text }, then: { h.plain.text = "y" }))
print("plain class inside, replace it:", fires({ _ = h.plain.text }, then: { h.plain = Plain() }))
  • true false true false true false false true false true
  • all true
  • only the append and the nested edit

Mental model. Observation is a guest list written while the party is on: only the people you actually spoke to during body get a call when something changes.

withObservationTracking (what SwiftUI wraps around body) records every property read during the tracking closure and fires once when any of them changes. The check shows the edges. A computed property tracks through the stored properties it reads (label reads count reads items), so appending fires, while an unrelated property (discount) does not. A property marked @ObservationIgnored never fires. An array is one property: replacing an element counts as a change to items, even when only items.count was read. A read that happens in a closure you store and call later was not inside the scope, so it is not tracked.

Nesting matters. An @Observable object inside another is tracked when you read through it (c.note.text fires), but a plain class held by an observable one is only tracked as a reference: editing its field does not fire, replacing it does. Writing an equal value to a property of an Equatable type did not fire in this Swift 6.4 toolchain, so redundant assignments are free, though that is an implementation detail I would not rely on for correctness. In a view the practical rules are: read what you display during body, keep non-UI bookkeeping @ObservationIgnored, make nested models observable, and remember the callback is one-shot, which SwiftUI handles by tracking again each render.

Tracking follows reads of observable stored properties made inside the scope.

Common traps

  • Holding a plain class inside an @Observable model and expecting field edits to update the UI. Make it observable too.
  • Reading in a deferred closure and expecting updates. The read was outside the tracking scope.

They might follow up with

  • Does an equal write skip the notification? It did in this toolchain; treat it as an optimisation.
  • Why does a one-shot callback still keep a view fresh? SwiftUI re-registers on every body run.

Related questions

  • 8.3.3 How does the Observation framework differ from ObservableObject, and how does tracking work?
  • 8.2.1 How does SwiftUI decide to re-run body, and how do you keep updates cheap?
  • 8.10.4 When exactly does ObservableObject publish, and why do nested objects and equal assignments surprise people?
  • 8.10.2 What is a Binding really, and how do derived, constant and transforming bindings behave?

← What is a Binding really, and how do derived, constant and…   When exactly does ObservableObject publish, and why do nested objects… →