@Observable observation outside of SwiftUI

I've just started tinkering with @Observable and have run into a question... What is the best practice for observing an Observable object outside of SwiftUI? For example:

@Observable
class CountingService {
    public var count: Int = 0
}

@Observable
class ObservableViewModel {
    public var service: CountingService

    init(service: CountingService) {
        self.service = service
        
        // how to bind to value changes on service? 
    }

    // suggestion I've seen that doesn't smell right
    func checkCount() {
        _ = withObservationTracking {
            service.count
        } onChange: {
            DispatchQueue.main.async {
                print("count: \(service.count)")
                checkCount()
            }
        }
    }
}

Historically using ObservableObject I'd have used Combine to monitor changes to service. That doesn't seem possible with @Observable and I don't know that I've come across an accepted / elegant solution? Perhaps there isn't one? There's no particular reason that CountingService has to be @Observable -- it's just nice and clean.

Any suggestions would be appreciated!

Answered by Matt Cox in 779766022

Sadly, there's no way to do this at the moment other than the approach you've taken which I agree doesn't smell right.

As you've found, the only approach that works right now is as using withobservationtracking(_:onchange:). Any Observable property that you access in the apply method will trigger onChange when it changes. But it will only trigger it once, so you must call withobservationtracking(_:onchange:) again to re-register the observation.

Accepted Answer

Sadly, there's no way to do this at the moment other than the approach you've taken which I agree doesn't smell right.

As you've found, the only approach that works right now is as using withobservationtracking(_:onchange:). Any Observable property that you access in the apply method will trigger onChange when it changes. But it will only trigger it once, so you must call withobservationtracking(_:onchange:) again to re-register the observation.

Thanks for the confirmation Matt! It's a bummer that this is the current solution for the scenario above given the elegance of Observable.

I don't believe that it's intentional, but a number of the recent (and big) API additions by Apple (Observable, SwiftData, et al.) don't seem especially compatible with "traditional" patterns like MVVM, MVP and the like.

I don't believe that it's intentional

Indeed. This feature was designed (somewhat) in the open via SE-0395 Observation, and you can read the related review threads to understand how we ended up where we did.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

You can use @Observable outside of SwiftUI with AsyncStream like this:

struct CountingService {
    
    @Observable
    class Model {
        public var count: Int = 0
    }
    
    let model = Model()
    
    static var shared = CountingService()    

    init() {
        Task { [model] in
            
            let modelDidChange = AsyncStream {
                await withCheckedContinuation { continuation in
                    let _ = withObservationTracking {
                        model.count
                    } onChange: {
                        continuation.resume()
                    }
                }
            }
            var iterator = modelDidChange.makeAsyncIterator()
            repeat {
                let x = model.count
                
                // do something
                
            } while await iterator.next() != nil
        }
    }
}

Or if you want the count in the stream, like this:

            let countDidChange = AsyncStream {
                await withCheckedContinuation { continuation in
                    let _ = withObservationTracking {
                        model.count
                    } onChange: {
                        continuation.resume()
                    }
                }
                return model.count
            }
            for await count in countDidChange {
                
            }

Inside of SwiftUI, @Observable is only designed for model data, for view data please use the View struct hierarchy with @State structs to model your view data and .task for async/await. You'll avoid consistency issues that way and can use many of the powerful features like environment and preferences which you would lose if you attempt to use classes for view data instead.

@Observable observation outside of SwiftUI
 
 
Q