Question Details

No question body available.

Tags

arrays swift swiftui observation-framework

Answers (3)

Accepted Answer Available
Accepted Answer
January 21, 2026 Score: 2 Rep: 293,275 Quality: High Completeness: 80%

@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( keyPath: KeyPath ) { $observationRegistrar.access(self, keyPath: keyPath) }

internal nonisolated func withMutation( keyPath: KeyPath, mutation: () throws -> U ) rethrows -> U { try $observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) }

private nonisolated func shouldNotifyObservers( lhs: U, rhs: U) -> Bool { true }

private nonisolated func shouldNotifyObservers( lhs: U, rhs: U) -> Bool { lhs != rhs }

private nonisolated func shouldNotifyObservers( lhs: U, rhs: U) -> Bool { lhs !== rhs }

private nonisolated func shouldNotifyObservers( 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.

January 21, 2026 Score: 1 Rep: 540,257 Quality: Low Completeness: 50%

Confirmed (not that you needed confirmation, but just letting you know). I ignored your code, wrote my own completely independent test bed from scratch, and saw what you saw. You can work around this by testing for containment before you call removeAll:

func removeCard() { if cards.contains(where: { $0 == "test" }) { cards.removeAll(where: { $0 == "test" }) } }

As for why it happens, I don't know, but I believe that removeAll does some pretty complicated stuff under the hood, in order to implement special efficiencies, so maybe that has something to do with it. The case is similar, I am guessing, to the fact that merely examining a value using inout, modifies that value:

func doNothing(_ array: inout [String]) {} doNothing(cards) // counts as modification
January 21, 2026 Score: 1 Rep: 475 Quality: Low Completeness: 50%

I’ll try to guess why this happens. We can assume it really works this way

removeAll(where:) is a mutating method. And Observation reacts to mutation, not to whether the value actually changed Swift - RangeReplaceableCollection.swift.

Even if no elements are removed, calling a mutating method on a value type (Array) is considered a state change, so observers are triggered Swift - Array.swift.

Here are official places to read more, short and factual:
Property observers behavior Swift Language Guide — Properties
Mutating methods and value semantics Swift Language Guide — Methods