Observing properties on an @Observable class outside of SwiftUI views

Published on: January 21, 2025

On iOS 17 and newer, you have access to the Observable macro. This macro can be applied to classes, and it allows SwiftUI to officially observe properties on an observable class. If you want to learn more about Observable or if you're looking for an introduction, definitely go ahead and check out my introduction to @Observable in SwiftUI.

In this post, I would like to explore how you can observe properties on an observable class. While the ObservableObject protocol allowed us to easily observe published properties, we don't have something like that with Observable. However, that doesn't mean we cannot observe observable properties.

A simple observation example

The Observable macro was built to lean into a function called WithObservationTracking. The WithObservationTracking function allows you to access state on your observable. The observable will then track the properties that you've accessed inside of that closure. If any of the properties that you've tried to access change, there's a closure that gets called. Here's what that looks like.

@Observable
class Counter {
  var count = 0
}

class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { 
      print("counter.count: \(counter.count)")
    } onChange: {
      self.observe()
    }
  }
}

In the observe function that’s defined on CounterObserver, I access a property on the counter object.

The way observation works is that any properties that I access inside of that first closure will be marked as properties that I'm interested in. So if any of those properties change, in this case there's only one, the onChange closure will be called to inform you that there have been changes made to one or more properties that you've accessed in the first closure.

How withObservationTracking can cause issues

While this looks simple enough, there are actually a few frustrating hiccups to deal with when you work with observation tracking. Note that in my onChange I call self.observe().

This is because withObservationTracking only calls the onChange closure once. So once the closure is called, you don’t get notified about any new updates. So I need to call observe again to once more access properties that I'm interested in, and then have my onChange fire again when the properties change.

The pattern here essentially is to make use of the state you’re observing in that first closure.

For example, if you're observing a String and you want to perform a search action when the text changes, you would do that inside of withObservationTracking's first closure. Then when changes occur, you can re-subscribe from the onChange closure.

While all of this is not great, the worst part is that onChange is called with willSet semantics.

This means that the onChange closure is called before the properties you’re interested in have changed so you're going to always have access to the old value of a property and not the new one.

You could work around this by calling observe from a call to DispatchQueue.main.async.

Getting didSet semantics when using withObservationTracking

Since onChange is called before the properties we’re interested in have updated we need to postpone our work to the next runloop if we want to get access to new values. A common way to do this is by using DispatchQueue.main.async:

func observe() {
  withObservationTracking { 
    print("counter.count: \(counter.count)")
  } onChange: {
    DispatchQueue.main.async {
      self.observe()
    }
  }
}

The above isn’t pretty, but it works. Using an approach based on what’s shown here on the Swift forums, we can move this code into a helper function to reduce boilerplate:

public func withObservationTracking(execute: @Sendable @escaping () -> Void) {
    Observation.withObservationTracking {
        execute()
    } onChange: {
        DispatchQueue.main.async {
            withObservationTracking(execute: execute)
        }
    }
}

The usage of this function inside of observe() would look as follows:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    print("counter.count: \(counter.count)")
  }
}

With this simple wrapper that we wrote, we can now pass a single closure to withObservationTracking. Any properties that we've accessed inside of that closure are now automatically observed for changes, and our closure will keep running every time one of these properties change. Because we are capturing self weakly and we only access any properties when self is still around, we also support some form of cancellation.

Note that my approach is rather different from what's shown on the Swift forums. It's inspired by what's shown there, but the implementation shown on the forum actually doesn't support any form of cancellation. I figured that adding a little bit of support for cancellation was better than adding no support at all.

Observation and Swift 6

While the above works pretty decent for Swift 5 packages, if you try to use this inside of a Swift 6 codebase, you'll actually run into some issues... As soon as you turn on the Swift 6 language mode you’ll find the following error:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    // Capture of 'self' with non-sendable type 'CounterObserver?' in a `@Sendable` closure
    print("counter.count: \(counter.count)")
  }
}

The error message you’re seeing here tells you that withObservationTracking wants us to pass an @Sendable closure which means we can’t capture non-Sendable state (read this post for an in-depth explanation of that error). We can’t change the closure to be non-Sendable because we’re using it in the onChange closure of the official withObservationTracking and as you might have guessed; onChange requires our closure to be sendable.

In a lot of cases we’re able to make self Sendable by annotating it with @MainActor so the object always runs its property access and functions on the main actor. Sometimes this isn’t a bad idea at all, but when we try and apply it on our example we receive the following error:

@MainActor
class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { [weak self] in
      guard let self else { return }
      // Main actor-isolated property 'counter' can not be referenced from a Sendable closure
      print("counter.count: \(counter.count)")
    }
  }
}

We can make our code compile by wrapping access in a Task that also runs on the main actor but the result of doing that is that we’d asynchronously access our counter and we’ll drop incoming events.

Sadly, I haven’t found a solution to using Observation with Swift 6 in this manner without leveraging @unchecked Sendable since we can’t make CounterObserver conform to Sendable since the @Observable class we’re accessing can’t be made Sendable itself (it has mutable state).

In Summary

While Observation works fantastic for SwiftUI apps, there’s a lot of work to be done for it to be usable from other places. Overall I think Combine’s publishers (and @Published in particular) provide a more usable way to subscribe to changes on a specific property; especially when you want to use the Swift 6 language mode.

I hope this post has shown you some options for using Observation, and that it has shed some light on the issues you might encounter (and how you can work around them).

If you’re using withObservationTracking successfully in a Swift 6 app or package, I’d love to hear from you.

Categories

SwiftUI

Subscribe to my newsletter