How to track changes in an Observable class outside of SwiftUI views


Greetings, traveler!

Apple introduced the Observation framework during WWDC 2023 to replace ObservableObject via the new Observable macro. This framework allows us to redraw SwiftUI views after applying changes to an Observable class. But what if we want to track these changes outside of SwiftUI views? There is a way to do this. Looking ahead, I want to say that this API currently seems suspicious. I will explain why this is the case at the end of this article.

withObservationTracking

You can use the withObservationTracking method for such a tracking. Check out this example:

import Observation
import Foundation

@Observable
final class Model {
    
    var name: String
    
    init(name: String) {
        self.name = name
        observe()
    }
    
    private func observe() {
        withObservationTracking {
            _ = name
        } onChange: { [weak self] in
            print(self?.name)
        }
    }
    
}

We have an Observable Model and want to track changes in its name property inside this class. We can do it with withObservationTracking method.

let model = Model(name: "Kate")
model.name = "Jack"

But we printed the old value. What’s wrong? Since the onChange closure was called before the “Jack” value was set, we just got the old value of the name property. To fix this behavior, we can wrap our code inside the DispatchQueue.main.async.

private func observe() {
    withObservationTracking {
        _ = name
    } onChange: {
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            
            print(name)
        }
    }
}

Alright then, let’s try something like this:

struct SwiftUIView: View {
    
    @State private var model: Model
    
    private var names = ["John", "Kate", "Paul"]
    
    init(model: Model) {
        self.model = model
    }
    
    var body: some View {
        Button {
            model.name = names[Int.random(in: .zero...names.count - 1)]
        } label: {
            Text("Tap Me")
        }
    }
    
}

We have the SwiftUI View, which can trigger changes in its model. Let’s tap the button several times.

As we can see in the console, our tracking worked only once. The second button tap has no effect. Due to the nature of this API, changes will only trigger the print() method once, so you must call withobservationtracking(_: on change :) again to restart the observation. You can do this by adding a recursion to your method.

private func observe() {
    withObservationTracking {
        _ = name
    } onChange: {
        DispatchQueue.main.async { [weak self] in
            guard let self else { return }
            
            print(name)
            observe()
        }
    }
}

Now, it is working as expected.

Is it appropriate to use this method frequently?

You can use this method to track changes anywhere, even in your UIKit modules. But, as you can see, this approach is not very convenient. It looks like an Observable class was not intended to be used like that. If you will take a look at ObservationTracking.swift, you will notice that this method is annotated with @_spi(SwiftUI). This may indicate that this method was not meant for common use. Yes, this method was marked as a public one, but there is a feeling that this was done only because there was no other option. Therefore, this may mean that this part of the API may be affected by changes in the future.