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 use the Observation framework in SwiftUI annotate your data with the macro @Observable. Note that @Observable can only be used with classes, not structs or other types.

import Observation
import SwiftUI

@Observable
final class Person {
    var name: String
}

struct ContentView: View {
    let person = Person(name: "John") // no property wrapper needed

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

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)

Changes are only tracked for properties involved in the layout. For instance, referencing the property in a button action doesn’t track the property.

Property Wrappers

Here is the new (iOS 17+) way to build your views:

  • Use @State for local state.
  • Use @Bindable if you need two-way binding ($ operator) to an Observable object.
  • Use @ObservationIgnored in the properties of an @Observable object to opt-out tracking.
  • Don’t use any property wrapper if you are just displaying properties from an Observable object.

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)
    }
}

Migration

Replace ObservableObject

  • 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 @State instead
  • @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 when you need binding syntax (the $ operator)
  • 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

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().

Internal Implementation

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

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