We Need to Talk About Observation
(Note: Every buzz-and-nonbuzz word of this post was painstakingly conceived and typed by hand using a real-ass keyboard and a real-ass text editor running on my personal computer in my home office outside Cleveland, Ohio. No GPUs were harmed in the making of this film.)
TL;DR
For the Impatient of Spirit among you: Apple has effectively deprecated the reigning paradigm of the ObservableObject protocol and @Published properties observed via the Combine framework, but they’ve only partially provided its replacement via the @Observable macro and the withObservationTracking free function. The gaps between the old way and the new way are worth careful consideration.
Out with the old…
Since the advent first of the React framework and then later other similar projects—SwiftUI, Jetpack Compose, etc.—the peppiest and most responsive apps these days are written as thin layers of declarative UI code that spontaneously react to changes in thick layers of object-oriented code. Those latter objects are sometimes called “view models” or “flow coordinators” or whatever you wish to call them. Naming aside, the reactive paradigm on Apple platforms has been reified by SwiftUI. It has usurped UIKit/AppKit as the dominant framework for new UI development.
What I find most exciting about SwiftUI isn’t just the fact that it’s declarative. What’s most exciting is the other half of my codebase. I’m excited about all the stuff that isn’t SwiftUI. Business logic. View models. UserProviderManagerHamburgerHelper. Files that only have import Foundation
at the top and nothing else. That stuff gets written entirely differently in a pure SwiftUI world, relative to how it was written for UIKit/AppKit. In the relatively-before-times, in the kinda-sorta-long-long-ago, there were DataSources and Delegates, IBOutlets and IBActions. Stuff worked like an old telephone switchboard: somebody had to plug the dataSource
cable into the SomethingSomethingDataSource jack in a wall of such holes. If anything worked, its because a giant tangle of loosely coupled, weakly-referenced properties got wired up just-in-time.
Ignoring for now, because we will discuss them below, the @Observable
macro and the Observation framework, contemporary application code that I regard as Pure and Faultless is this: a thin shell of incredibly dumb UI code wrapping a core of intelligent business logic factored into a constellation of focused, domain-specific objects:
@MainActor final class MyModel: ObservableObject {
@Published var stuff = Stuff.initialStuff()
@Published var error: PresentableError?
var disableNextButton: Bool { /* hard stuff */ }
func next() { /* complex stuff */ }
}
struct MyView: View {
@StateObject var model = MyModel()
var body: some View {
StuffEditor(stuff: $model.stuff)
Button("Next") {
model.next()
}.disabled(model.disableNextButton)
}
}
I’ll select one thing to highlight in the above sample code: notice that the “Next” button action is just model.next()
. There’s no need to guard model.stuff.isValidAndEverything else { return }
, because that’s handled by .disabled(model.disableNextButton)
. My view is exceptionally easy to discard and rewrite for Liquid Glass without having to rewrite and retest any business logic. This kind of code is easy to write and maintain, and is wine to you, wine and comfort, if you care about shipping new stuff fast and good and cheap, all three.
I want to show one more example before I get into how Apple has taken an axe to major portions of this approach.
It’s not just declarative UI that has been enabled by the reactive pattern. It’s also the behaviors that spring up between non-UI objects. Once an app reaches a sufficient level of real-world complexity, your app will have all kinds of important relationships:
// View relying on model:
view <---- object
// Object relying on delegate/data-source:
object <---- object
// One-to-many observations:
object ----> [object, object, object, ...]
It’s that third one that has been accelerated in recent years by the Combine framework, the ObservableObject protocol, and the @Published property wrapper.
I’ve written and reviewed no shortage of code that looks a lot like this contrived beauty (which I have shorn of Swift’s concurrency isolation grievances for brevity):
@MainActor final class UserCoordinator: ObservableObject {
@Published private(set) user: User?
}
@MainActor final class SyncEngine: ObservableObject {
@Published private(set) var status: Status = .idle
private var cancellables: Set<AnyCancellable> = []
func observe(_ coordinator: UserCoordinator) {
coordinator.$user
.removeDuplicates()
.sink { [weak self] user in
self?.restartOrCancelSync(newUser: user)
}
.store(in: &subscriptions)
}
}
I was able to write the above without bothering to switch to Xcode from my preferred Markdown editor to get the syntax correct. It’s an easy pattern to reproduce, and it works alongside SwiftUI in a quietly supportive fashion. Need a view that displays the sync progress? Just pass a reference to SyncEngine
to your view and connect a ProgressView to that status
property, bada-bing. Important: you do not need a View in order for the SyncEngine to do its job. The SyncEngine is fully capable of subscribing to changes to the user
property of the UserCoordinator and programmatically, spontaneously respond to the activity of the UserCoordinator, without either the UI or the UserCoordinator needing any awareness of this behavior.
It just works.
But Apple has taken an axe to all that.
…and in with the new
Combine has been softly deprecated since structured concurrency was debuted, more softly in iOS 13 when AsyncSequence was released, but even more so over the subsequent years with improvements to structured concurrency and the introduction of the @Observable macro in iOS 17. It is now possible to subscribe to long-running streams of elements emitted asynchronously using structured concurrency:
func subscribe<S>(
to sequence: S
) async where S: AsyncSequence, S.Element = User?, S.Failure = Never {
for await user in sequence {
restartOrCancelSync(newUser: user)
}
}
The @Observable macro has usurped ObservableObject as the de rigeur way to write a “view model” type of object:
@MainActor @Observable final class MyModel {
var stuff = Stuff.initialStuff()
var error: PresentableError?
var disableNextButton: Bool { /* hard stuff */ }
func next() { /* complex stuff */ }
}
struct MyView: View {
@State var model = MyModel()
var body: some View {
StuffEditor(stuff: $model.stuff)
Button("Next") {
model.next()
}.disabled(model.disableNextButton)
}
}
There are a ton of problems with that code, as-written. It might compile, but it wouldn’t pass code review if you ran it past me at work. But before I get to that, let’s talk about what’s good about it, because I do appreciate the ways in which this new stuff is a step forward:
- Breathing room: - You don’t need a ton of
@Published
property wrappers. Observability is opt-out, not opt-in. Also (not pictured here), if your view doesn’t need editing capability, you can use a plain-oldlet
property without the@State
. - Optimization: - Only the properties of an @Observable object that are accessed by your little slice of the view hierarchy are invalidated during a UI update. The old thing was a big, blunt hammer that incurred too many unnecessary redraws.
- Nesting - If you want to refactor MyModel to be a composition of several smaller @Observable objects, you can do that without having to rewrite your view hierarchy. This is huge.
But there are the problems with the code as I’ve written it above. Exploring those problems, and the paucity of their solutions, will shed light on the shortcomings in the new status quo:
-
Needless object recreation. - See that line where the
@State
property replaced the old@StateObject
? That’s more than cosmetic. StateObject memoized its initialization via an escaping autoclosure over the default value, so that over the view’s lifetime on-screen, only one instance of the model would have been instantiated. The State wrapper offers no such feature. Anything that causes the MyView struct (not the on-screen view it represents, the struct itself, easy to misunderstand) to be recomputed by its ancestors—which is astonishingly easy in any real-world application—will discard the old instance and create a new one. I’ve seen this happen a lot with form validation and keyboards. At worst, a view model getsr-e-c-r-e-a-t-e-d
on every keypress. If you’ve got anything expensive happening inside that view model’sinit
method, buddy: watch out. -
No streamlined way to avoid object recreation. - If you want to avoid that needless recreation, and bring back that old StateObject behavior, Apple’s recommendation has been—and still is even in OS 26!—to do the following tedious
onAppear
modifier dance:
struct MyView: View {
@State private var model: Model?
var body: some View {
content.onAppear {
if model == nil {
model = Model()
}
}
}
@ViewBuilder var content: some View {
if let model {
MyActualView(model: $model)
} else {
Color.clear
}
}
That’s very difficult to generalize. There ain’t no way to write a property wrapper to do it. At best, you can write a reusable generic View
that takes two blocks: one that instantiates the state and another that provides the view. However you solve it, it is tedious and annoying to write code like this. But it’s totally necessary if your Model
class, unlike my example above, requires any initialization parameters—which it should, because you ought not to be using networking and database singletons.
But I have a bigger problem with this new paradigm, and it isn’t visible from a UI code sample.
Where did you go, programmatic observation?
Remember I wrote this above:
What’s most exciting is the other half of my codebase.
In a sufficiently complex, real-world application, the thin layers of SwiftUI code are only half the picture. There is usually, if you’re separating concerns, a constellation of domain-specific objects that perform duties that aren’t user-visible or aren’t visible right away. These duties often require establishing one-to-one or one-to-many observation from one object to other objects that it knows nothing about. In the world of the ObservableObject protocol and @Published properties, the exact same mechanism that powers the relationship between an object and a view also powers the relationship between an object and another object. But with the @Observable macro, the object-to-object relationship is different. Much different.
In my opinion, it’s undercooked.
iOS 17: withObservationTracking comes withGreatResponsibility
In iOS 17 the withObservationTracking
free function is the only way for something to subscribe to changes to an @Observable macro’ed object:
func withObservationTracking<T>(
_ apply: () -> T,
onChange: @autoclosure () -> () -> Void
) -> T
It’s behavior may really surprise you, if your previous mental model has been shaped, like mine has, by Combine. Let’s break it down:
withObservationTracking
this is a synchronous function without any Swift concurrency isolation sugar except that which you get by default for nonisolated, synchronous functions.apply
This is a synchronous function parameter that is invoked exactly once, immediately. The value returned from this function is theT
returned from withObservationTracking. You don’t actually have to return anything butVoid
if you don’t need a return value. But you must access properties of your @Observed object(s) insideapply
. Only the properties that are accessed will be tracked for future changes. It is sufficient just toprint(myModel.value)
inside theapply
if that’s all you need. It is headscratchingly difficult to grok how to correctly implement anapply
body if you’re bringing a Combine mental model to the table.onChange
This is a synchronous, escaping, autoclosed function. It will be called either once or zero times, and no more. It is not like a Combinesink
, which is called upon every emitted value. It is called either zero times (if the observed object never changes the properties you accessed inapply
) or exactly once (if one or more of those properties change in the future).
If you want to have sustained, ongoing observation of all future changes, then you have to add a recursive tail-call to the onChange
that (probably reëntrantly) calls withObservationTracking
again.
Returning to my example above of a SyncEngine observing a UserCoordinator, let’s look at how this would work with @Observable:
@MainActor @Observable final class UserCoordinator {
private(set) user: User?
}
@MainActor @Observable final class SyncEngine {
private(set) var status: Status = .idle
func observe(_ coordinator: UserCoordinator) {
withObservationTracking {
restartOrCancelSync(newUser: coordinator.user)
} onChange: { [weak self, weak coordinator] in
// recursive tail-call:
guard let self, let coordinator else { return }
self.observe(coordinator)
}
}
}
What’s missing from that picture?
-
Discoverability - It isn’t easy to understand how to use this function. It’s a free function, not a
$member
of the object you’re trying to observe, which makes it harder to discover. -
Cancellation - How do I cancel my observation? How many times will this run? With Combine, you were given an explicit
AnyCancellable
from thesink
method. Your subscription lived until you either calledcancel()
or discarded the object. There’s no such thing returned here. You might wonder then if you’re supposed to use structured concurrency,Task.cancel()
, but that’s not it, either. There’s no interplay with structured concurrency here. The answer is this: “cancellation” means “don’t do a recursive tail-call”, but that isn’t the same thing as cancellation. Your subscription, so-called, is always a one-shot, and it never resolves until the next time theonChange
handler fires, which might never happen. If you don’t want to observe changes anymore, you have to do something like (a) rely on[weak self]
to cause your object to disappear, and/or (b) add aprivate var dontObserveAnymore: Bool
property to your object and set that dirty bit totrue
to prevent future recursive tail-calls via, like, aguard
or something. It’s entirely up to you. There is no established pattern, and definitely no off-the-shelf API guiding your hand.
It all feels phoned-in, hardly the replacement for the opinionated, curated set of public APIs offered by the Combine framework.
iOS 26: Observations struct
New in iOS/macOS/etc 26 (technically new in Swift 6.2, but since the runtime no longer gets embedded in binaries, it’s unavailable prior to OS 26), there is now the Observations struct. Don’t confuse it with the Observation package to which it belongs (don’t miss that dangling, plural ess…)
Observations is an Apple-provided way for one object to subscribe to long-running changes to some other, @Observable-macro’ed object. It is written to use the AsyncSequence protocol. Despite the fact that withObservationTracking
was released in OS 17, Observations has not been back-ported and requires OS 26.
Usage of Observations is like any other AsyncSequence. You use the for in await
keywords. Here’s my SyncEngine example, rewritten to use Observations:
@MainActor @Observable final class UserCoordinator {
private(set) user: User?
}
@MainActor @Observable final class SyncEngine {
private(set) var status: Status = .idle
func observe(_ coordinator: UserCoordinator) async {
let obs = Observations.untilFinished { [weak coordinator] in
guard let coordinator else { return .finish }
return coordinator.user
}
for await user in obs {
restartOrCancelSync(newUser: user)
}
}
}
This is a mix of steps forward and steps backward. I’ll start with the positive:
The Positive
-
Structured Concurrency - The new API is written with structured concurrency in mind. The closure passed to
Observation.untilFinished
(as well as to the unboundedinit
alternative) is annotated with@isolated(any)
and@_inheritActorContext
and other goodies that make it play nicely even in Swift 6 language mode. You can stop your stream from emitting subsequent values by either throwing an error or returning.finish
. More on the topic of cancellation and stream-stopping below, it’s not all rosy. -
Generics - The struct is generic over both the elements it produces and the errors it throws. This lends itself to terse, idiomatic Swift code. The
withObservationTracking
function didn’t allow for throwing errors at all.
But there are shortcomings with this new API, some of which are particularly gnarly to resolve.
The Gnarly
The gnarly bits are really just one, big, gnarly problem with multiple facets: it is hard to reason about the lifetime of the Observations async sequence and the lifetimes of the objects involved. But I’ll try to separate out those problems here because I like bulleted lists.
- Cancellation - It still isn’t as clear, compared to Combine’s
AnyCancellable
, how you’re supposed to cancel an Observations struct. There’s no obvious visible API surface for doing so. You might wonder if Task cancellation is the way. You would be correct. To implement cancellation you need to wrap the entire thing in a Task, store that task in an instance variable, and determine key points in the lifecycle of your object to cancel that task:
@MainActor @Observable final class SyncEngine {
private(set) var status: Status = .idle
private var userObservation: Task<Void, any Error>?
deinit {
userObservation?.cancel()
}
func observe(_ coordinator: UserCoordinator) async {
userObservation = Task { [weak self] in
let coordinator = self?.coordinator
let obs = Observations.untilFinished { [weak coordinator] in
guard let coordinator else { return .finish }
return coordinator.user
}
for await user in obs {
if self == nil {
throw CancellationError()
}
self?.restartOrCancelSync(newUser: user)
}
}
}
}
There are problems with the above code, though, and I don’t recommend doing things exactly that way.
-
Object lifetimes - It is important that your
Task
and yourObservations
structs weakly-capture bothself
and whatever object you’re trying to observe. But it’s still easy to get it wrong. It just as hard here as it was in the “old” days of Objective-C block capture semantics. A variable captured weakly in an outer scope can still live longer than you wish. See how I have that statement inside the Tasklet coordinator = self?.coordinator
? That local variablecoordinator
is going to remain in memory for as long as the Task body has lexical scope, and that is as long as theawait
down below is still waiting on the next value, which is potentially forever if you’ve thus created a retain cycle between the Task that is sustaining the lifetime of the coordinator and the coordinator, never producing another value, sustaining the Task. -
Actor isolation hassles with cancellation - That
deinit
method isn’t isolated to the Main Actor, not yet. You can’t access a mutablevar
property from the deinit. You would need to wrap that property in some kind of synchronization box, like a Mutex, which comes with its own kinds of hassles and boilerplate.
Side note: the source code for Observations is open to public view. If you squint hard enough, you can figure out that it’s basically a gigantic, V-Ger like edifice around withObservationTracking
with recursive tail-calls handled as a combination of state machinery and the AsyncSequence protocol. I find it simultaneously (concurrently?) both nifty and somewhat deflating that Apple’s solution to programmatic observation of @Observable macro objects is still propped up by the ho-hum withObservationTracking
free function.
Alas.
What We’ve Lost
It’s worth comparing the Observations example above (the version where I wrapped it in a Task and manually managed cancellation inside a deinit
method) versus the exact same equivalent behavior implemented via Combine observation of a @Published property:
private var cancellables: Set<AnyCancellable> = []
func observe(_ coordinator: UserCoordinator) {
coordinator.$user
.sink { [weak self] user in
self?.restartOrCancelSync(newUser: user)
}
.store(in: &subscriptions)
}
}
Look how much more succinct that is! And how little it relies on needing to grok whatever is going on with an enclosing structured concurrency Task from which observe(_:)
happens to be called.
With Combine (a) you don’t have to override deinit
, (b) you don’t have to specialize the cancellables
property with generics which means (c) you can store multiple subscriptions in a single property with ease, (d) you don’t have to remember to weakly-reference the object being observed because it’s done for you, (e) AnyCancellable does the minimum-viable thing for you automatically by cancelling itself when destroyed, which (f) in the majority of use cases is more than good enough to limit Combine observation streams to the correct duration.
Further recommendations
Even despite all my qualms, I remain convinced that the @Observable macro is the right path forward, both for Apple and their third-party developer community. I really appreciate the ways that it works with SwiftUI (and, worth a note in passing, Swift Data). I just wish that Apple would have been as opinionated about non-UI programming when designing the public APIs for @Observable as they were with the Combine framework. This new stuff feels penciled in and incremental, which would be OK if it weren’t absolutely fundamental to key architectural choices we have to make when building an app.
When you’re choosing between ObservableObject and @Observable, you should not limit the scope of your consideration solely to what’s best for SwiftUI. The choice you make will have structural implications on non-UI code.
Understand what you’re building before you build it.
Corrections
- I previously misidentified AsyncSequence as being released in iOS 18. It was released in iOS 13. (Thanks rdsquared).