ARTICLE AD BOX
@Observable classes are observed based on accessor calls, not direct equality comparisons.
The key point here is that calling removeAll, a mutating method, calls the _modify accessor, which is implemented to unconditionally notify observers regardless of equality. If it had called the setter instead, an additional equality check would be performed and observers will not be notified if the new and old values are equal.
Here is a simple example to demonstrate this:
@Observable class Foo { var x = 0 } extension Int { // a mutating func that does nothing! mutating func g() {} } struct ContentView: View { @State var f = Foo() var body: some View { let _ = Self._printChanges() Text("\(f.x)") Button("f.x.g()") { // this ends up calling the _modify accessor // and causes a view update f.x.g() } Button("f.x = 0") { // this ends up calling the setter // and does not cause a view update f.x = 0 } } }Let's expand the @Observable on Foo:
This expands to:
class Foo { var _x: Int var x: Int = 0 { @storageRestrictions(initializes: _x) init(initialValue) { _x = initialValue } get { access(keyPath: \.x) return _x } set { guard shouldNotifyObservers(_x, newValue) else { _x = newValue return } withMutation(keyPath: \.x) { _x = newValue } } _modify { access(keyPath: \.x) _$observationRegistrar.willSet(self, keyPath: \.x) defer { _$observationRegistrar.didSet(self, keyPath: \.x) } yield &_x } } @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar() internal nonisolated func access<T>( keyPath: KeyPath<Foo, T> ) { _$observationRegistrar.access(self, keyPath: keyPath) } internal nonisolated func withMutation<T, U>( keyPath: KeyPath<Foo, T>, _ mutation: () throws -> U ) rethrows -> U { try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) } private nonisolated func shouldNotifyObservers<U>(_ lhs: U, _ rhs: U) -> Bool { true } private nonisolated func shouldNotifyObservers<U: Equatable>(_ lhs: U, _ rhs: U) -> Bool { lhs != rhs } private nonisolated func shouldNotifyObservers<U: AnyObject>(_ lhs: U, _ rhs: U) -> Bool { lhs !== rhs } private nonisolated func shouldNotifyObservers<U: Equatable & AnyObject>(_ lhs: U, _ rhs: U) -> Bool { lhs != rhs } }Notice that the setter of x first checks shouldNotifyObservers, but there is no such check in the _modify accessor.
Why is there a _modify accessor in the first place and how is it different from the setter?
The purpose of the _modify accessor is to reduce creating copies when modifying properties in ways that can be done "in place", e.g. calling a mutating function.
Consider what happens if x has only a setter, in which case f.x.g() will call this setter instead of _modify. In order to call this setter, a copy of x needs to be created, so that it can be passed to the newValue parameter of the setter. To illustrate with pseudocode,
var copy = f.x copy.g() f.setX(newValue: copy)If x is very large struct but g only modifies a very small part of x, this copy is wasteful. The _modify accessor allows this to be done in place. See the line yield &_x in the _modify accessor above. This line is what invokes g, effectively doing _x.g(), without creating a copy.
See this pitch for more info.
Now you should see why there is no shouldNotifyObservers check in the _modify accessor. To do this check, you must have the new value and old value at the same time, but the whole point of the _modify accessor is to do the change in place. You cannot know what the new value will be, before modifying _x to become that new value. You could store the old value in a local variable like this, but then you cannot call willSet before it is actually set:
_modify { access(keyPath: \.x) // this creates a copy, defeating the very purpose of a _modify accessor! let oldValue = _x yield &_x if shouldNotifyObservers(oldValue, _x) { // willSet is called *after* _x is changed ??? _$observationRegistrar.willSet(self, keyPath: \.x) _$observationRegistrar.didSet(self, keyPath: \.x) } }Finally, I should mention that @Observable didn't use to generate _modify accessors, as far as I remember. This is new behaviour in Xcode 26. I think it used to only generate getters and setters, and without the shouldNotifyObservers check. The macro might change its behaviour in the future too.
