When exactly does ObservableObject publish, and why do nested objects and equal assignments surprise people?
Predict first
What do the sinks see?
import Combine
final class Child: ObservableObject { @Published var n = 0 }
final class Parent: ObservableObject {
@Published var title = "t"
@Published var items: [Int] = []
let child = Child()
}
let p = Parent()
var seen: [String] = []
let willChange = p.objectWillChange.sink { seen.append("will change, title is still \(p.title)") }
p.title = "u"
print(seen); seen = []
p.child.n = 5
print("nested object change reaches the parent:", !seen.isEmpty)
p.items = [1]; seen = []
p.items = [1]
print("assigning an equal array fires:", seen.count)
willChange.cancel()
let projected = p.$title.sink { v in seen.append("$title sent \(v) while title is \(p.title)") }
seen = []
p.title = "v"
print(seen)
projected.cancel()
- willChange sees the old title / nested change does not reach the parent / equal array fires once / $title sends the new value while title is still old
- willChange sees the new title
- the nested change reaches the parent
Mental model. objectWillChange is a “wet paint” sign hung before the painting starts: everyone is told early, and anyone who looks right now still sees the old colour.
@Published sends objectWillChange before the new value is stored. In the check the sink runs while title is still t, and the $title publisher likewise delivers the new value while the property still holds the old one. That is why code reacting inside a sink must use the value it is given (or hop to the next run-loop turn), not re-read the object. SwiftUI is fine with this because it re-renders later, after the change has landed.
Two surprises. Nesting does not propagate: the parent only publishes for its own @Published properties, so a change inside child never reaches an @ObservedObject parent (the check prints false); forward it by subscribing to the child’s objectWillChange or, better, move to @Observable. And there is no equality check: assigning an equal array still publishes, so every observer re-evaluates. Compare before assigning when updates are frequent. Mutating an array element in place publishes once per mutation, so batch edits into a local copy. All of this is why @Observable is cheaper by default, while ObservableObject stays for pre-iOS-17 targets and when you want a Combine publisher.
@Published announces before the value changes and has no equality check.
Common traps
- Reading the object inside the
objectWillChangesink. It still has the old value. - Nesting
ObservableObjects and expecting the outer view to update. It does not.
They might follow up with
- How do you forward a child’s changes? Subscribe to its
objectWillChangeand send your own. - Is a struct in
@Publishedcheaper? Any mutation publishes the whole property.
Related questions
- 8.3.3 How does the Observation framework differ from ObservableObject, and how does tracking work?
- 8.10.3 What exactly does Observation track: computed properties, ignored properties, collections, nested objects and…
- 8.2.1 How does SwiftUI decide to re-run body, and how do you keep updates cheap?
← What exactly does Observation track: computed properties, ignored… If a parent changes one environment value, which child views re-run… →