History of the question
Before Swift 5.9, reactive programming in SwiftUI relied on the ObservableObject protocol combined with the Combine framework. Developers manually annotated properties with @Published to synthesize publishers, or called objectWillChange.send() to notify views of mutations. This pattern suffered from coarse-grained updates—any property change triggered a full view body re-evaluation—and mandated reference semantics, preventing the use of structs for complex view models. The Observation framework was introduced to provide fine-grained, automatic reactivity without explicit publisher declarations.
The problem
The core challenge was detecting property access and mutation without explicit boilerplate while maintaining type safety and high performance. Traditional solutions required either manual key-value observing (KVO), which is stringly-typed and fragile, or wrapper types that cluttered the domain model. The system needed to intercept read and write operations to register dependencies dynamically, but without the runtime overhead of method swizzling or the architectural constraints of ObservableObject's reference-counted identity propagation.
The solution
Swift employs the @Observable macro, which combines peer, member, and accessor macro capabilities. During compilation, the macro transforms the annotated class by injecting a private ObservationRegistrar instance. It then rewrites every stored property into computed accessors that wrap reads with _$observationRegistrar.track(self, keyPath: ...) and writes with willSet/didSet notifications. This expansion automatically synthesizes conformance to the Observable protocol and implements the required observationRegistrar computed property. SwiftUI integrates with this registrar during view body evaluation, registering itself as an observer only for properties actually accessed, thus achieving granular updates without manual Combine setup.
@Observable class SettingsViewModel { var isDarkModeEnabled = false var notificationCount = 0 } // Conceptual compiler expansion: class SettingsViewModel { private let _$observationRegistrar = ObservationRegistrar() var isDarkModeEnabled: Bool { get { _$observationRegistrar.track(self, keyPath: \.isDarkModeEnabled) return _isDarkModeEnabled } set { _$observationRegistrar.willSet(self, keyPath: \.isDarkModeEnabled) let oldValue = _isDarkModeEnabled _isDarkModeEnabled = newValue _$observationRegistrar.didSet(self, keyPath: \.isDarkModeEnabled, oldValue: oldValue) } } private var _isDarkModeEnabled = false // ... identical pattern for notificationCount }
You are architecting a real-time financial dashboard SwiftUI application displaying live stock prices, user portfolio totals, and news feeds. The view model contains thirty distinct properties, ranging from Boolean UI flags to complex chart data structures.
Initially, the team implemented this using ObservableObject with @Published wrappers on every property. This caused severe performance degradation: when a single stock price updated, the entire dashboard recomputed because ObservableObject notifies that "something changed" in the entire object, lacking granularity. The code was also verbose, requiring repetitive @Published var declarations and manual AnyCancellable storage to prevent memory leaks in subscriptions.
The team evaluated three architectural approaches to solve the performance and boilerplate issues.
The first approach involved manual Combine optimization. They would create individual PassthroughSubject instances for each critical property and subscribe to specific updates using .onReceive. The advantage was precise control over which UI components refreshed. However, the disadvantage was massive code bloat—thirty subjects required thirty subscriptions and error-prone manual memory management with Set<AnyCancellable>, rendering the codebase unmaintainable.
The second approach suggested using SwiftUI's @State with value-type view models. They would treat the view model as an immutable value and replace it on every mutation. The advantage was natural value semantics and automatic equality checks preventing redundant updates. The disadvantage was the loss of reference identity; every mutation created a new instance, breaking ScrollView position restoration and making complex object coordination impossible due to identity loss.
The third approach adopted the @Observable macro. By annotating the class with @Observable and removing all @Published attributes, the compiler automatically transformed properties to use the ObservationRegistrar. The advantage was twofold: the syntax remained clean with plain var declarations, and SwiftUI automatically tracked which properties were accessed in each view's body, updating only those specific subviews. The disadvantage was the requirement to migrate to Swift 5.9 and train the team on macro debugging techniques.
The team selected the third approach because it eliminated 200 lines of Combine subscription code while solving the granularity problem. They observed a 40% reduction in CPU usage during high-frequency price updates. The result was a responsive dashboard where individual stock price labels updated independently without triggering layout recalculations for the static news feed section.
Why does the Observation framework require the observed type to be a class (reference semantics), and what compilation error occurs if @Observable is applied to a struct?
The @Observable macro expands by injecting an ObservationRegistrar as a stored property and implementing the Observable protocol. Structs are value types with copy-on-write semantics; each mutation conceptually creates a new instance with a distinct identity. The ObservationRegistrar maintains internal state—observer lists and tracking scopes—that must persist across mutations to maintain the observation graph. If applied to a struct, mutations would copy the registrar state incorrectly, breaking the connection between observers and the instance. The compiler prevents this by generating an error indicating that the macro cannot add the required stored property to a value type in a way that satisfies the Observable protocol's requirements for stable identity, or more specifically, that the resulting type cannot conform to Observable because it lacks the necessary reference stability.
How does the Observation framework handle nested observable objects, and why is there no projected value (like $property) for individual properties as there was with @Published?
When an @Observable class contains a property that is itself an @Observable class, the framework tracks access at the property level, not by automatically recursively observing nested objects. Accessing outer.inner.name registers a dependency on the inner property of the outer object. If the inner instance is replaced entirely, observers are notified. However, changes to inner.name do not notify the outer object's observers unless the outer object explicitly tracks the inner one. Unlike Combine, there is no projected value concept for individual properties in Observation because the framework uses direct property tracking via the registrar rather than publisher streams. The $ syntax in SwiftUI for Observation is instead used on the entire instance when wrapped with @Bindable (e.g., @Bindable var viewModel: SettingsViewModel allows $viewModel.isDarkModeEnabled), not on individual property declarations.
What specific thread-safety guarantees does the Observation framework provide, and how does it interact with Swift concurrency actors?
The ObservationRegistrar itself is not inherently thread-safe; it assumes serialized access to the observable object. When an @Observable class is isolated to an actor (such as @MainActor), all mutations and observations automatically occur on that actor's context, preventing data races. The framework ensures that observation callbacks respect the observer's isolation domain using Sendable checks. A critical implementation detail is that the tracking mechanism uses TaskLocal storage to maintain the current observation scope during view body execution. This means observation registration is implicitly tied to the current Task's context and cannot leak across unstructured concurrency boundaries without explicit transfer, ensuring that observations are only active during the specific asynchronous transaction where they were registered.