1. Usage
  2. The New Way to Build Views
  3. Benefits
  4. withObservationTracking
  5. Migration
  6. Internal Implementation
  7. Troubleshooting

The Observation framework provides a macro based implementation of the observer design pattern. It’s designed to be used with SwiftUI and available in the Swift standard library since iOS 17.

Usage

To create an observable class annotate it with the macro @Observable. Note that this macro can only be used with classes, not structs or other types.

import Observation

@Observable
final class Person {
    var name: String // no need for @Published!
}

To observe changes from SwiftUI no action is needed, just add a reference to the object:

import SwiftUI

struct PersonView: View {
    let person: Person // no need for @State!

    var body: some View {
        Text(person.name) // will refresh when name changes
    }
}

Note that in this example we were using a reference passed from a parent view.

To hold an observable instance:

  • use let if only the properties change
  • add @State only if the instance itself changes

Example, the parent view changes the properties:

struct ParentView: View {
    let person = Person(name: "Alice")
    var body: some View {
        PersonView(person: person)
            .task {
                person.name = "Bob"
            }
    }
}

Example: the parent view changes the instance itself:

struct ParentView: View {
    @State let person = Person(name: "Alice")
    var body: some View {
        PersonView(person: person)
            .task {
                person = Person(name: "Bob")
            }
    }
}

Whether the parent uses @State or not, the child keeps using let. Note that if you add @State to the child, it will preserve the instance that is first passed. That is, the child won’t observe the instance change performed by its parent. Usually you want parent and child to share state so this is generally a mistake.

The child only needs changes if it is going to change the properties of the instance held by its parent. In this case we use @Bindable in the child, but note we don’t use $ in the parent, only in the child.

To change properties of an instance you don’t own use @Bindable:

struct PersonView: View {
    @Bindable let person: Person // to change properties of the parent

    var body: some View {
        TextField("Name", text: $person.name)
    }
}
struct ParentView: View {
    @State let person = Person(name: "Alice")
    var body: some View {
        PersonView(person: person) // do NOT use $ here
    }
}

There are other esoteric ways to perform property changes on a parent:

struct PersonView: View {
    @Environment(Person.self) var person: Person

    var body: some View {
        // creating a @Bindable at the top of your body
        @Bindable var person = self.person
        TextField("Name", text: $person.name)
    }
}

struct PersonView: View {
    @Environment(Person.self) var person: Person

    var body: some View {
        // instantiating a Binding struct inline
        TextField("Name", text: .init(
            get: { person.name },
            set: { person.name = $0 }
        ))
    }
}

To pass a @Bindable in an init use the same format as with @State:

init(person: Person) {
    _person = Bindable(wrappedValue: person)
}

Finally, here is a copy paste-able example to play with.

Parent/child relationship

Copy this code in a fresh project to get the following:

import Observation
import SwiftUI

@Observable
final class Person {
    var name: String // no need for @Published!
    init(name: String) {
        self.name = name
    }
}

struct PersonView: View {
    // Use @Bindable to change properties
    @Bindable var person: Person

    var body: some View {
        VStack(alignment: .leading) {
            Text("Child").font(.headline)
            Text("person name: \(person.name)")
            TextField("Name", text: $person.name)
                .textFieldStyle(.roundedBorder)
        }
    }
}
struct ParentView: View {
    // Use @State if the instance itself changes
    @State var person = Person(name: "Alice")

    var body: some View {
        VStack(alignment: .leading) {
            Text("Parent").font(.headline)
            Text("person name: \(person.name)")
            Button("change instance") {
                person = Person(name: "Mallory")
            }
            Button("change property") {
                person.name = "Bob"
            }
            PersonView(person: person).padding(.top, 16)
        }
    }
}

@main
struct PersonsApp: App {
    var body: some Scene {
        WindowGroup {
            ParentView().padding().frame(alignment: .leading)
        }
    }
}

The New Way to Build Views

Here a summary of the new (iOS 17+) way to build your views:

  • Use @Observable macro on classes whose properties you need to observe.
  • Use @State for local independent state.
  • Use @ObservationIgnored in the properties of an @Observable object to opt-out tracking.
  • Use @Bindable for two-way binding with Observable classes
  • Use @Binding for two-way binding with non-Observable types (struct, enum, actor)

The Observation framework does not use @StateObject as several LLM still recommend.

Example.

Property wrapper use

import Observation
import SwiftUI

@Observable final class Person {
    var name: String
    @ObservationIgnored var age: Int // won’t be tracked
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

struct PersonEditorView: View {
    @Bindable var person: Person  // for two-way binding
    @State private var isEditing = false  // local state
    
    var body: some View {
        VStack(spacing: 16) {
            // using local @State
            Toggle("Enable Editing", isOn: $isEditing)
            
            // two-way binding with @Bindable
            TextField("Name", text: $person.name)
                .textFieldStyle(.roundedBorder)
                .disabled(!isEditing)
            
            // age is not tracked!
            Text("Age: \(person.age)")
                .foregroundColor(.gray)
            
            // name is tracked
            Text("Current name is: \(person.name)")
        }
        .padding()
    }
}

struct ContentView: View {
    let person = Person(name: "John", age: 30)
    
    var body: some View {
        PersonEditorView(person: person)
    }
}

Benefits

Property changes automatically refresh the layout, even if the property

  • is used through a computed property
  • is nested through several @Observable objects
  • is outside the view hierarchy (e.g. a global variable or singleton)
  • is nil or it is a property of an optional object currently set to nil

Changes are only tracked for properties that are involved in the layout. For instance, referencing the property in a button action doesn’t track the property if it’s not being displayed.

withObservationTracking

withObservationTracking is the only programmatic use allowed in this framework. It fires just once, so if you want continuous observation you’ll need a recursive call to re-establishes observation on every change (see below). Example.

func withContinuousObservation<T>(
    of value: @escaping @autoclosure () -> T, 
    execute: @escaping (T) -> Void
) {
    withObservationTracking {
        execute(value())
    } onChange: {
        Task { @MainActor in
            withContinuousObservation(of: value(), execute: execute)
        }
    }
}

Diff property changes

import Foundation
import OSLog

func stringDump(_ object: Any) -> String {
    var output = ""
    dump(object, to: &output)
    return output
}

func diffStringDumps(_ dump1: String, _ dump2: String) -> String {
    let lines1 = dump1.components(separatedBy: .newlines)
    let lines2 = dump2.components(separatedBy: .newlines)
    
    var diff = ""
    
    let maxLines = max(lines1.count, lines2.count)
    
    for i in 0..<maxLines {
        if i < lines1.count && i < lines2.count {
            if lines1[i] != lines2[i] {
                diff += "- \(lines1[i])\n+ \(lines2[i])\n"
            }
        } else if i < lines1.count {
            diff += "- \(lines1[i])\n"
        } else if i < lines2.count {
            diff += "+ \(lines2[i])\n"
        }
    }
    
    return diff
}

@Observable
class Person: CustomStringConvertible {
    var name: String
    var age: Int
    
    init(name: String, age: Int) {
        self.name = name
        self.age = age
        observe()
    }
    
    func observe() {
        let oldDesc = description
        _ = withObservationTracking {
            // tracking name
            self.name
        } onChange: {
            // This closure runs when the Observable macro still didn't apply the change.
            // The async schedules the closure in the next runloop iteration so we can observe the change.
            DispatchQueue.main.async {
                let diff = diffStringDumps(oldDesc, self.description)
                print(diff)
                self.observe()
            }
        }
    }
    
    var description: String {
        """
          name: \(name)
          age: \(age)
        """
    }
}

var person = Person(name: "Alice", age: 30)
person.name = "Bob"

RunLoop.current.run(mode: .default, before: Date.init(timeIntervalSinceNow: 3))

/*
-   name: Alice
+   name: Bob
*/

Another way, courtesy of nilcoalescing.com: Create an AsyncStream from withObservationTracking().

Migration

Replace ObservableObject on classes

  • Use @Observable macro instead
  • Remove @Published on properties
  • If class has incompatible annotation (like @ModelActor), conform to Observable protocol instead
  • Example

 

// Pre-iOS 17
class DataStore: ObservableObject {
    @Published var count: Int // opt-in to track
    let identifier: UUID // not tracked
}

// iOS 17
@Observable class DataStore {
    var count: Int // tracked
    @ObservationIgnored let identifier: UUID // opt-out to not track
}

Replace @StateObject

  • Use let to change properties, or @State if you also change the instance itself
  • @State now handles both value and reference types
  • Example

 

// Pre-iOS 17
struct TodoView: View {
   // reference type needs @StateObject
   @StateObject var store = TodoStore()
   ...
}

// iOS 17
struct TodoView: View {
   // @State handles both value and reference types
   @State var store = TodoStore()
   ...
}

Replace @ObservedObject

  • Use @Bindable for @Observable classes in a child when you need $ syntax
  • Keep using @Binding for value types
  • Keep using @Binding for @Model objects
  • Example

 

// Pre-iOS 17
struct TodoView: View {
   @ObservedObject var store: TodoStore // reference type needs @ObservedObject
   @Binding var count: Int // value type keeps using @Binding
   @Binding var model: TodoModel // @Model types keep using @Binding
   ...
}

// iOS 17
struct TodoView: View {
   @Bindable var store: TodoStore // use @Bindable when $ binding needed
   @Binding var count: Int // value types unchanged
   @Binding var model: TodoModel // @Model unchanged
   ...
}

Replace @EnvironmentObject

 

// Pre-iOS 17
struct TodoView: View {
   @EnvironmentObject var store: TodoStore
   ...
}
ContentView()
   .environmentObject(todoStore)

// iOS 17
@Observable class TodoStore { // mark it @Observable
    var todos: [Todo]
    ...
}
struct TodoView: View {
   @Environment(TodoStore.self) var store // replace
   ...
}
ContentView()
   .environment(TodoStore.self, todoStore) // new injection by type
   .environment(\.todoStore, todoStore)    // new injection by keyPath

Internal Implementation

Generated Code

Given this code

@Observable
final class Person {
    var name: String
}

That @Observable macro generates this:

final class Person: Observable {
    private let _$observationRegistrar = ObservationRegistrar()

    private var _name: String

    var name: String {
        get {
            _$observationRegistrar.access(self, keyPath: \.name)
            return _name
        }

        set {
            _$observationRegistrar.willSet(self, keyPath: \.name)
            _name = newValue
            _$observationRegistrar.didSet(self, keyPath: \.name)
        }

        _modify {
            _$observationRegistrar.access(self, keyPath: \.name)
            _$observationRegistrar.willSet(self, keyPath: \.name)
            defer { _$observationRegistrar.didSet(self, keyPath: \.name) }
            yield &_name
        }
    }

    init(name: String) {
        self._name = name
    }
}

A high level explanation is that it decorates the property accessors so

  • getters register a view as observer of a property,
  • and set/_modify notifies the view to refresh.

The different phases access/willSet/didSet will be useful for animations and transitions.

Source

The observation framework is available at github/swiftlang

Tap to open new tab. Renders fine in Chrome. Class diagram

Workflow

  1. A view renders data from the Person Observable object.

    Text(person.name)

  2. SwiftUI runtime reads the code, notices it has an observable object, and creates a tracking wrapper similar to this:

    withObservationTracking {
        _ = Text(person.name)
    } onChange: {
        // schedule this view for refresh (private API)
    }

  3. During view rendering, the getter for name triggers an access call:

    registrar.access(self, keyPath: \.name)
    This call records in thread-local storage that this property was accessed during the view's render. Internally, the ObservationRegistrar maintains a mapping linking this specific property (object + keyPath) to the view update function provided in the onChange callback.

  4. When name is modified:
    • willSet and didSet are called
    • The registrar looks up all observers (view update functions) registered for this property
    • Each registered observer is notified, which in this case triggers SwiftUI to schedule the view for refresh using the onChange callback registered during the tracking (step 2).

Thread-safety

The framework is thread-safe to prevent data races, which supports the use of withObservationTracking outside SwiftUI. The ObservationRegistrar uses an internal _ManagedCriticalState which wraps a mutex (using the new Swift 6 Mutex). All state mutations in ObservationRegistrar happen within this critical region:

  • willSet
  • didSet
  • access tracking
  • registration of observers

A takeaway point is that while mutations are thread-safe, we still need to mutate only on the main thread for objects used in SwiftUI. While SwiftUI might have internal mechanisms to handle updates from a background thread, it’s not documented or guaranteed. The safer approach is to mutate Observable properties on the main thread, especially when they drive UI updates.

The beginning of a general framework

…the current implementation of Observable was pretty much tailor made for SwiftUI (so much so that I wonder if even withObservationTracking(_:onChange:) should have been under SPI...) Anyway, the plan is to enhance it for more general use-cases – but that hasn't happened yet. -source

Looking at the private enum ObservationKind we see several events:

private enum ObservationKind {
    case willSetTracking(@Sendable (AnyKeyPath) -> Void)
    case didSetTracking(@Sendable (AnyKeyPath) -> Void)
    case computed(@Sendable (Any) -> Void)
    case values(ValuesObserver)
}
  • willSet, didSet could be useful for
    • Cascading updates where order matters
    • Debugging and logging
    • State validation before changes
    • Transaction management
    • Undo/redo systems
  • ValuesObserver seems designed to implement specialized patterns. Example.

Streaming changes

The following example doesn’t compile because certain Observation APIs aren’t public. Nonetheless, it illustrates how we could listen for changes on arbitrary objects.

let sensor = TemperatureSensor()
let history = TemperatureHistory()

// A possible use: 
// custom storage for changes of an arbitrary object.
sensor._$observationRegistrar.context.registerValues(
    for: [\.currentTemp], 
    storage: history
)

sensor.currentTemp = 20.5
sensor.currentTemp = 21.0
sensor.currentTemp = 21.5

// Object being tracked
@Observable class TemperatureSensor {
    var currentTemp: Double = 0
}

// Custom storage
class TemperatureHistory: ValueObservationStorage {
    private var readings: [Double] = []
    private let maxReadings: Int
    
    init(maxReadings: Int = 10) {
        self.maxReadings = maxReadings
    }
    
    override func emit<Element>(_ element: Element) -> Bool {
        guard let temp = element as? Double else { return false }
        readings.append(temp)
        if readings.count > maxReadings {
            readings.removeFirst()
        }
        print("New reading: \(temp)°C")
        print("History: \(readings)")
        return false // false = keep observing
    }
    
    override func cancel() {
        print("Stopped temperature monitoring")
        readings = []
    }
}
    

Third party implementations

If we look at the swift-perception from Pointfree the mechanism is very similar to Apple’s. However, not having access to the SwiftUI internal magic, clients need to do the wrapping themselves calling WithPerceptionTracking.

ObservationBP is another implementation which replicates Apple’s very closely. It uses this ObservationView.swift wrapper. The refresh is forced through a change in local @State, same trick used by WithPerceptionTracking.

Troubleshooting

'@Observable' cannot be applied to actor type

Actors are designed to protect their mutable state through actor isolation, ensuring that only one task can access the actor’s state at a time. But the Observation framework is designed to work synchronously and notify inmediately. If you want to mix both models you have to

  • Store the state to observe in a @Observable type, then store this type inside the actor.
  • Or store the actor in an @Observable type, call it asynchronously, then update state with the result.

Value must conform to Observable

Two common reasons for this:

  • Primitive values don’t conform to Observable.
  • Optionals are not not Observable either, even when the wrapped type is.
// error: primitives are not Observable
@Bindable var content: String

// error: 'init(wrappedValue:)' is unavailable: 
// The wrapped value must be an object that conforms to Observable
@Bindable var foo: ChallengeEditingViewModel?

// works: ChallengeEditingViewModel is Observable and not optional
@Bindable var viewModel: ChallengeEditingViewModel

If you need your data to be optional, consider creating an enum with two states, one of them being the @Observable class that you need to track.

Invalid redeclaration of synthesized property

Problem: @Observable and @Bindable they both try to generate a backing storage property. This happens when we want to track and modify properties on a view model.

@Observable
final class ChallengeEditingViewModel {
    @Bindable var challenge: ChallengeDetail
    // Invalid redeclaration of synthesized property '_challenge'

Solution: If you need @Bindable also add @ObservationIgnored.

@Observable
final class ChallengeEditingViewModel: Observable {
    @ObservationIgnored @Bindable var challenge: ChallengeDetail

Observation will still work as demonstrated in this example.

Example of @ObservationIgnored @Bindable

Updates to the parent and child view happen with each press of the counter, even when the viewmodel uses @ObservationIgnored @Bindable.

import SwiftUI

@Observable
class Counter {
   var count: Int = 0
}

@Observable
class TestViewModel {
   @ObservationIgnored @Bindable var counter: Counter

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

struct ParentView: View {
   let viewModel = TestViewModel(counter: Counter())

   var body: some View {
       VStack(spacing: 20) {
           Text("Counter: \(viewModel.counter.count)")
           Button("Increase") {
               viewModel.counter.count += 1
           }

           ChildView(viewModel: viewModel)
       }
       .padding()
   }
}

struct ChildView: View {
   let viewModel: TestViewModel

   var body: some View {
       VStack {
           Text("Child View")
           Text("Counter in child: \(viewModel.counter.count)")
       }
       .padding()
       .background(Color.gray.opacity(0.2))
       .cornerRadius(8)
   }
}

Note that in this example let viewModel didn’t need @Bindable. If Counter had been added directly to the view, it would need @Bindable to track the changes, like in the code below. However, it is not the case if the view owns an Observable reference object that owns another reference object where the state is.

struct ChildView: View {
    // @Bindable needed because we bind directly to a Counter
    @Bindable var counter: Counter
...
The key distinction is:
  • When a view directly owns an @Observable object it needs @Bindable
  • When a view owns an @Observable object that contains other @Observable objects it doesn't need @Bindable