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:

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:

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:

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?

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

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.

@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.

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

|  10 Sep 2025