Understanding when SwiftUI re-renders an @Observable

I have a question according to the function of the @Observable-macro:

I am using an @Observable object as a source of truth in my view. My class has an array, but I would like to reorganize this data in my view. I was asking myself, what would be the best way to do that and I came up with the idea of using a computed property:

@Observable
class MyObservable {
	var items = [0, 1, 2, 3, 4]
}

struct TestView: View {
	var myObservable = MyObservable()
	
	var filtered: [Int] {
		myObservable.items.filter { $0 % 2 == 0 }
	}
	
	var body: some View {
		VStack(alignment: .leading) {
			Text("\(filtered)")
		}
	}
}

This works as pretended. But I was asking myself: Will this view be re-rendered, when any other property in the class will be changed?

I tested it like this:

@Observable
class MyObservable {
	var items = [0, 1, 2, 3, 4]
	var temp = 1
}

struct TestView: View {
	var myObservable = MyObservable()
	
	var filtered: [Int] {
		print("rerenderred")
		return myObservable.items.filter { $0 % 2 == 0 }
	}
	
	var body: some View {
		VStack(alignment: .leading) {
			Text("\(filtered)")
			
			Text("\(myObservable.temp)")
			Button("Increase Counter") {
				// question: does this lead to a re-render?
				myObservable.temp += 1
			}
			
			Button("test") {
				// this of course will lead to a re-render
				myObservable.items.append(myObservable.items.last!+1)
			}
		}
	}
}

Unfortunately, every time I press the button "Increase counter", I get the print "rerendered". So the computed property will be calculated again, altough the property items will not be changed after clicking the button.

I guess I could change this behaviour by using .onChange(of: myObservable.items ...?

The other idea is to take out the computed property to another view:

@Observable
class MyObservable {
	var items = [0, 1, 2, 3, 4]
	var temp = 1
}

struct TmpView: View {
	
	var myObservable: MyObservable
	
	var filtered: [Int] {
		print("rerenderred")
		return myObservable.items.filter { $0 % 2 == 0 }
	}
	
	var body: some View {
		Text("\(filtered)")
	}
}

struct TestView: View {

	var myObservable = MyObservable()

	var body: some View {
		VStack(alignment: .leading) {
			TmpView(myObservable: myObservable)
			
			Text("\(myObservable.temp)")
			Button("Increase Counter") {
				// this does NOT lead to a re-render of TmpView
				myObservable.temp += 1
			}
			
			Button("test") {
				myObservable.items.append(myObservable.items.last!+1)
			}
		}
	}
}

But is there maybe a more elegant way?

Computed properties that perform linear-time work can be good candidates for memoization or caching. Filtering is a pure function. Two equal inputs should produce two equal outputs. You might want to explore a property wrapper that takes an array as input and produces the filtered array as output.

Hi, thanks for your answer and the idea. But if I understand it correct, your solution is not possible, because you cannot apply a property wrapper to a property of an @Observable, as this macro turns all the properties to computed properties what leads to the error: Property wrapper cannot be applied to a computed property

Other way around. A property wrapper contains an Observable. A change on the Observable recomputes your component body. That either recomputes your derived data or returns your cached results. I assume you would want State to persist your data across multiple component values.

FWIW this is more valuable when the derived data is greater than linear time… like sorting. The equality operator on the input array would be linear-time in the worst case… so you are performing a linear-time operation to prevent a linear-time operation from taking place. But you do get constant-time equality operations when these array point to the same copy-on-write data storage object reference.

To explain what you're seeing when you move filtering into TmpView, there's one big difference between it and its parent TestView: TmpView isn't paying attention to the value of temp.

SwiftUI records accesses to the properties of Observable objects during evaluations of body, and only re-renders that view if a property it used actually changes. TmpView isn't using temp, so it doesn't bother re-rendering that view. In this case that has the side-effect of saving you the extra filtering.

As for the question of optimizing the filtering of values, in my opinion this all comes down to who (as in what view) needs to use them and when. For small amounts of data honestly this probably doesn't matter, but I'm assuming in your real use case it does.

If you know that you will always need the filtered values whenever MyObservable.items changes, the solution there could be to move that work into MyObservable, which would mean you could consistently enforce a contract ("when items changes filter it") and access the filtered results anywhere you have MyObservable:

@Observable
class MyObservable {
    
    private(set) var filteredItems: [ Int ] = []
    
    var items: [ Int ] = [] {
        didSet {
            filteredItems = items.filter { $0 % 2 == 0 }
        }
    }
    
    init() {
        self.items = [0, 1, 2, 3, 4]
        self.filteredItems = items.filter { $0 % 2 == 0 }
    }
    
}

(You could even go a step further and make this lazier.)

On the other hand, if you know that you'll only ever need filtering at the level of TestView, View.onChange is a decent option to only do the work of filtering when items changes, but really you could do that in several different ways.

3 Likes

To my understanding, if you actually access a property of an @Observable in a view, the entire view will be redrawn UNLESS the property is accessed in the closure of a ForEeach or Loop or a few other special cases.

If you don't want the view to re-render on the button press, you need to either abstract the incrementing into a function, or pass myObservable to another view that will re-render.

I just went through this process in my app that I updated to use @Observable, and managed to eliminate the needless redraws by accessing the properties in functions, or by just passing the observable to the view that actually need to use the property.

Since you are accessing properties of the observable in the button in the view that calls TmpView, TestView and TmpView will be redrawn whenever any property of myObservable changes.

Unfortunately I don't think this is quite correct. Observation is triggered if an accessed property is set, not if it changes. You can see this by setting a property equal to itself. body should be recomputed, though there may be no rendering update depending on SwiftUI's implementation details. That Observable (or SwiftUI) doesn't require Equatable conformance in the state should also reiterate this fact. I did see some PRs in the Swift repo to enhance Observable to do observation-on-change, but I don't know if that's actually going to happen.

4 Likes

That's an important distinction — you're right, SwiftUI isn't tracking equality.

Checking if equatable values changed was added to the set accessor after the release of 6.0. Hopefully it’ll be in 6.1.

2 Likes

Hmm… I didn't see much discussion about the shouldNotifyObservers pattern before this landed. Was there any public evolution pitch about this?

1 Like

(@vanvoorden your comment on that pull request was very thorough and a great catch — according to what you noticed these changes would result in unexpected behavior.)

1 Like

The addition of the shouldNotifiyObservers was landed as an optimization to the code-generation; it is not intended to be API for external (outside of the type) calls. Those types of things don't usually go through evolution; however if it is causing problems we should figure out how to resolve it since that can be (in some scenarios a very good benefit to SwiftUI's performance).

2 Likes

Respectfully (please assume good intent when I suggest this)… if withObservationTracking(_:onChange:) is shipping as a public API… and shouldNotifyObservers alters the behavior of that API in non-trivial ways (other than fixing something we clearly understood to be a bug)… would that not be a very good candidate for opening a pitch and proposal evolution review for more input from the community before we ship to main?

1 Like

I think your counter-example defies the strict Equatable contract though. From the docs,

Equality implies substitutability—any two instances that compare equally can be used interchangeably in any code that depends on their values.

If that was upheld by your custom type, the changes to observation tracking would have no observable effect on client code. So, I think with that in mind, the changes have no undocumented changes to observable behaviour.

1 Like

Expanding on that idea… here is what the documentation for Equatable has to say about identity:[1]

Equality is Separate From Identity

The identity of a class instance is not part of an instance’s value.

The implication here could be that Equatable does imply two instances are interchange WRT code that depends on their values… but code that depends on their identity (assuming we are talking about reference types) should be able to continue its orthogonal tests for equality by identity.


  1. Equatable | Apple Developer Documentation ↩︎

While true, this change will also break uses of state = state used to trigger additional, if redundant, observations, sometimes used to work around SwiftUI or other reactive bugs. Personally I don't think that use case is important enough to warrant blocking this much needed improvement, but there may still be an impact due to outside factors the developer can't control.

3 Likes

While I agree with @bzamayo that your Equatable implementation is incorrect (if you want to consider identity as part of equality, you need to always consider it), I do agree it's strange that this sort of optimization was implemented by preventing the set rather than the emission of the observation. This goes against every expectation I would have (@Observable should not change the externally visible behavior of types except by adding observation) and seems like it would break the local didSet observer. @Philippe_Hausler was there a reason for this approach rather than allowing the set and the optimizing the emission of observation itself?

2 Likes

This is in reference to the check for identity equality in the check for value equality?

extension Car: Equatable {
  static func == (lhs: Car, rhs: Car) -> Bool {
    if lhs === rhs {
      return true
    }
    guard
      lhs.name == rhs.name
    else {
      return false
    }
    guard
      lhs.needsRepairs == rhs.needsRepairs
    else {
      return false
    }
    return true
  }
}

If two Car instances are equal by identity they must be equal by value. If two Car instances are not equal by identity we confirm value equlity from the stored instance properties. Could you please help me find the faulty logic?

This seems like a key part: I think users (myself at the top of that list) would appreciate and want optimizations like this that make prevent views from updating needlessly. What feels out of place is that it changes the intuitive behavior of setters: values users would expect to be held won't be, on types and properties they've defined themselves.

1 Like

Yes, you return true if the identity is the same, but don't return false if they're not. Technically I think it violates the substitution principle, which generally means the properties of equal things should also be equal, including their inequality, but I'm not enough of a mathematician to be sure.