Skip to content

SwiftUI: Observable macro under the hood

Published: at 01:44 PM

Introduction

WWDC 2023 brought one of the most significant shifts in how SwiftUI models are written. The @Observable macro, introduced as a part of a new Observation framework, replaces the old ObservableObject approach entirely and it does so in a way that’s both simpler to use and more efficient at runtime.

This article breaks down exactly what happens when you annotate a class with @Observable, from compile-time transformation to fine-grained SwiftUI re-renders.

Table of contents

Open Table of contents

What the Macro Actually Does at Compile Time

@Observable is not just a protocol conformance. It’s a full code transformation. At compile-time, two things happen to your class:

  1. Protocol conformance is added. Your class begins conforming to the Observable marker protocol. It has no runtime representation and carries no requirements. It simply signals to the compiler that this type participate in the observation system.

  2. Stored properties are rewritten as computed properties. This is the core trick. Each stored property gets a private backing store (e.g. _name) annotated with @ObservationIgnored, and the original property becomes a computed one with an instrumented getter and setter.

// What you write:
@Observable class UserModel {
    var name: String = ""
}

// What the macro generates (simplified):
class UserModel: Observable {
    @ObservationIgnored private var _name: String = ""
    private let _$observationRegistrar = ObservationRegistrar()

    var name: String {
        get {
            access(keyPath: \.name)
            return _name
        }
        set {
            withMutation(keyPath: \.name) {
                _name = newValue
            }
        }
    }
}

The Three Building Blocks of Access Tracking

Once properties are transformed, observation works through three coordinated mechanisms:

The key word is specific. Unlike the old @Published + objectWillChange pipeline that fired for any property change on the whole object, this system tracks at the individual key path level.

How SwiftUI Connects to This: withObservationTracking

SwiftUI bridges into the Observation framework via a global function withObservationTracking(_:onChange:). When SwiftUI evaluates your view’s body, it wraps that evaluation in withObservationTracking. During this pass, every access(keyPath:) call is intercepted and recorded. The result is a precise dependency graph: this view depends on these specific properties of these specific objects.

When any of those properties are mutated, and only those, SwiftUI schedules a re-render of the affected view. Nothing else is invalidated.

Ownership and Observation Are Now Separate Concerns

This is arguably the cleanest conceptual improvements in the new model. Previously, ObservableObject and @StateObject served dual process: they both established observation and managed object lifetime. That conflation caused bugs (the classic “use @StateObject not @ObservedObject to avoid re-creation” confusion).

With @Observable, these conserns are cleanly split:

Creating Bindings with @Bindable

Because @Observable objects are not property wrappers, there is no automatic $ projected value. To create bindings to their properties for use in TextField, Toggle, and etc, you use the @Bindable wrapper:

struct EditView: View {
    @Bindable var model: UserModel

    var body: some View {
        TextField("Name", text: $model.name)
    }
}

@Bindable is lightweight and can also be used as a local variable inside body if you only need a binding in one specific place.

Performance: Fine-Grained Invalidation

The most impactful runtime benefit is granular re-rendering. Under the old model, a single @Published change anywhere on an ObservableObject called objectWillChange.send(), invalidating every view that held a reference to that object regardless of which property those views actually read.

With @Observable the system knows exactly which properties each view accessed. A view that only reads model.name will not re-render when model.score changes.

For models with many properties and large view hierarchies, this difference in render pressure can be substantial.

Summary

@Observable is a clean, well-designed macro that makes the observation system explicit and transparent. At its core, it:

The old ObservableObject approach will continue to work, but for any new code targeting iOS 17+, @Observable is the better default in virtually every situation.

Thanks for reading

If you enjoyed this post, be sure to follow me on Twitter to keep up with the new content.


avatar

Nikita Vasilev

A software engineer with over 8 years of experience in the industry. Writes this blog and builds open source frameworks.


Next Post
Becoming an IEEE Senior Member: Application Guide