Using the Observations framework to observe model properties

Published on: September 24, 2025

Starting with Xcode 26, there's a new way to observe properties of your @Observable models. In the past, we had to use the withObservationTracking function to access properties and receive changes with willSet semantics. In Xcode 26 and Swift 6.2, we have access to an entirely new approach that will make observing our models outside of SwiftUI much simpler.

In this post, we'll take a look at how we can use Observations to observe model properties. We'll also go over some of the possible pitfalls and caveats associated with Observations that you should be aware of.

Setting up an observation sequence

Swift's new Observations object allows us to build an AsyncSequence based on properties of an @Observable model.

Let's consider the following @Observable model:

@Observable 
class Counter {
  var count: Int
}

Let's say we'd like to observe changes to the count property outside of a SwiftUI view. Maybe we're building something on the server or command line where SwiftUI isn't available. Or maybe you're observing this model to kick off some non-UI related process. It really doesn't matter that much. The point of this example is that we're having to observe our model outside of SwiftUI's automatic tracking of changes to our model.

To observe our Counter without the new Observations, you'd write something like the following:

class CounterObserver {
  let counter: Counter

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

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

This uses withObservationTracking which comes with its own caveats as well as a pretty clunky API.

When we refactor the above to work with the new Observations, we get something like this:

class CounterObserver {
  let counter: Counter

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

  func observe() {
    Task { [weak self] in
      let values = Observations { [weak self] in
        guard let self else { return 0 }
        return self.counter.count 
      }

      for await value in values {
        guard let self else { break }
        print("counter.count: \(value)")
      }
    }
  }
}

There are two key steps to observing changes with Observations:

  1. Setting up your async sequence of observed values
  2. Iterate over your observation sequence

Let's take a closer look at both steps to understand how they work.

Setting up an async sequence of observed values

The Observations object that we created in the example is an async sequence. This sequence will emit values whenever a change to our model's values is detected. Note that Observations will only inform us about changes that we're actually interested in. This means that the only properties that we're informed about are properties that we access in the closure that we pass to Observations.

This closure also returns a value. The returned value is the value that's emitted by the async sequence that we create.

In this case, we created our Observations as follows:

let values = Observations { [weak self] in
  guard let self else { return 0 }
  return self.counter.count 
}

This means that we observe and return whatever value our count is.

We could also change our code as follows:

let values = Observations { [weak self] in
  guard let self else { return "" }
  return "counter.count is \(self.counter.count)"
}

This code observes counter.count but our async sequence will provide us with strings instead of just the counter's value.

There are two things about this code that I'd like to focus on: memory management and the output of our observation sequence.

Let's look at the output first, and then we can talk about the memory management implications of using Observations.

Sequences created by Observations will automatically observe all properties that you accessed in your Observations closure. In this case we've only accessed a single property so we're informed whenever count is changed. If we accessed more properties, a change to any of the accessed properties will cause us to receive a new value. Whatever we return from Observations is what our async sequence will output. In this case that's a string but it can be anything we want. The properties we access don't have to be part of our return value. Accessing the property is enough to have your closure called, even when you don't use that property to compute your return value.

You have probably noticed that my Observations closure contains a [weak self]. Every time a change to our observed properties happens, the Observations closure gets called. That means that internally, Observations will have to somehow retain our closure. As a result of that, we can create a retain cycle by capturing self strongly inside of an Observations closure. To break that, we should use a weak capture.

This weak capture means that we have an optional self to deal with. In my case, I opted to return an empty string instead of nil. That's because I don't want to have to work with an optional value later on in my iteration, but if you're okay with that then there's nothing wrong with returning nil instead of a default value. Do note that returning a default value does not do any harm as long as you're setting up your iteration of the async sequence correctly.

Speaking of which, let's take a closer look at that.

Iterating over your observation sequence

Once you've set up your Observations, you have an async sequence that you can iterate over. This sequence will output the values that you return from your Observations closure. As soon as you start iterating, you will immediately receive the "current" value for your observation.

Iterating over your sequence is done with an async for loop which is why we're wrapping this all in a Task:

Task { [weak self] in
  let values = Observations { [weak self] in
    guard let self else { return 0 }
    return self.counter.count 
  }

  for await value in values {
    guard let self else { break }
    print("counter.count: \(value)")
  }
}

Wrapping our work in a Task, means that our Task needs a [weak self] just like our Observations closure does. The reason is slightly different though. If you want to learn more about memory management in tasks that contain async for loops, I highly recommend you read my post on the topic.

When iterating over our Observations sequence we'll receive values in our loop after they've been assigned to our @Observable model. This means that Observations sequences have "did set semantics" while withObservationTracking would have given us "will set semantics".

Now that we know about the happy paths of Observations, let's talk about some caveats.

Caveats of Observations

When you observe values with Observations, the first and main caveat that I'd like to point out is that memory management is crucial to avoiding retain cycles. You've learned about this in the previous section, and getting it all right can be tricky. Especially because how and when you unwrap self in your Task is essential. Do it before the for loop and you've created a memory leak that'll run until the Observations sequence ends (which it won't).

A second caveat that I'd like to point out is that you can miss values from your Observable sequence if it produces values faster than you're consuming them.

So for example, if we introduce a sleep of three seconds in our loop we'll end up with missed values when we produce a new value every second:

for await value in values {
  guard let self else { break }
  print(value)
  try await Task.sleep(for: .seconds(3))
}

The result of sleeping in this loop while we produce more values is that we will miss values that were sent during the sleep. Every time we receive a new value, we receive the "current" value and we'll miss any values that were sent in between.

Usually this is fine, but if you want to process every value that got produced and processing might take some time, you'll want to make sure that you implement some buffering of your own. For example, if every produced value would result in a network call you'd want to make sure that you don't await the network call inside of your loop since there's a good chance that you'd miss values when you do that.

Overall, I think Observations is a huge improvement over the tools we had before Observations came around. Improvements can be made in the buffering department but I think for a lot of applications the current situation is good enough to give it a try.

Categories

Swift

Expand your learning with my books

Practical Core Data header image

Learn everything you need to know about Core Data and how you can use it in your projects with Practical Core Data. It contains:

  • Twelve chapters worth of content.
  • Sample projects for both SwiftUI and UIKit.
  • Free updates for future iOS versions.

The book is available as a digital download for just $39.99!

Learn more