Using Observations to observe @Observable model properties
Published on: September 24, 2025Starting 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
:
- Setting up your async sequence of observed values
- 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.