@Observable Internals: The Macro, The Registrar, And What ObservableObject Got Wrong
The Observation framework, introduced in iOS 17 and Swift 5.9, replaced the Combine-based ObservableObject model with a macro-driven, per-property-access-tracking system1. The change looks small at the call site (one @Observable macro instead of : ObservableObject plus @Published everywhere), but the runtime behavior is different in a way that affects performance, correctness, and the migration path. The shift, in one sentence: views that didn’t read a changed property no longer re-evaluate when that property changes.
The post walks the framework’s internals against Apple’s documentation and the SE-0395 proposal2. The frame is “what the macro actually generates and why,” because most teams adopt @Observable for the syntax and miss the structural shift in update propagation, which is where the real performance gain (and the migration traps) live.
TL;DR
@Observableis a Swift macro that expands a class into a type conforming to theObservablemarker protocol, with an_$observationRegistrar: ObservationRegistrarinstance synthesized as a stored property3.- Each property’s getter wraps
_$observationRegistrar.access(self, keyPath:). Each setter wraps_$observationRegistrar.withMutation(of:keyPath:_:). The registrar tracks which scopes accessed which key paths. - The replacement vocabulary:
class Foo: ObservableObjectbecomes@Observable class Foo.@Published var namebecomesvar name.@StateObject var foo = Foo()becomes@State var foo = Foo().@EnvironmentObjectbecomes@Environment(Foo.self).@ObservedObject var foobecomes just using the property. @Bindableis the new property wrapper for creating bindings to an observable instance’s properties (replaces some@ObservedObjectuse cases for binding).- The migration trap:
@Statewith a reference type behaves differently from@StateObjectin subtle ways around view identity. Apps that swap them blindly can produce confusing initialization behavior on view rebuilds.
The Macro Expansion
When the compiler sees @Observable, it expands the type by adding three things3:
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
The expansion (simplified) generates:
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... same pattern for email and preferences
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
Three structural changes:
The registrar. A private ObservationRegistrar instance owns the tracking state. The registrar is the bridge between mutations on the model and re-evaluations of dependent scopes. The macro marks it @ObservationIgnored so the registrar itself doesn’t get tracked.
Property storage rewriting. Each declared stored property becomes a private backing field plus a computed property whose getter and setter call into the registrar. The compiler-generated accessors are what makes per-property tracking work.
Conformance to Observable. The marker protocol that the registrar’s API expects. The protocol has no requirements; it’s a conformance check, not an interface contract.
The Registrar’s Job
ObservationRegistrar does two things3:
Track access. When withObservationTracking { ... } onChange: { ... } (the underlying tracking API SwiftUI uses for view bodies) runs the closure, the registrar records every (self, keyPath) pair that gets read. The set of accessed paths is the scope’s “dependency footprint.”
Trigger invalidation. When a property is mutated, the registrar finds every scope that accessed that specific keyPath and triggers its onChange closure. Scopes that didn’t access the keyPath are unaffected.
The contrast with ObservableObject is the structural shift. ObservableObject’s objectWillChange publisher fires on every @Published mutation, and all subscribers receive the notification. SwiftUI’s view-body machinery uses the publisher to know “something changed; re-evaluate.” Re-evaluation runs against the full view; SwiftUI then computes which dependent views actually changed and updates only those, but the body re-evaluation already happened. With @Observable, the body re-evaluation itself is gated: if the body didn’t read the changed property, it doesn’t re-run.
For a UserProfile with three properties and a view that reads only name, the difference is real: an @ObservableObject model triggers body re-evaluation on email and preferences changes too; an @Observable model does not. In a complex app with many models and many views, the cumulative savings are significant.
Migration Mapping
The migration vocabulary, side-by-side4:
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo (or @Bindable var foo: Foo for bindings) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
The @Bindable wrapper deserves a separate note. It’s the new way to create Bindings to an @Observable instance’s properties:
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
Without @Bindable, the $profile.name syntax doesn’t work because @Observable types don’t automatically provide projected values. With it, every property gets a binding form. Use @Bindable when a child view needs two-way binding into a parent’s observable model; use a plain reference (var profile: UserProfile) when the child only reads.
The @State vs @StateObject Trap
The migration line that causes the most production bugs: @StateObject var foo = Foo() becomes @State var foo = Foo(). The change compiles. The behavior diverges through a subtle mechanism: how the default-value expression is evaluated5.
Both @State and @StateObject preserve the instance across SwiftUI’s view rebuilds when the view’s identity is stable; both keyed-by-identity backing stores throw away parent-driven re-initializations. The difference is in when the initializer expression runs.
@StateObject declares its parameter through @autoclosure. The Foo() initializer expression is wrapped and only evaluated when SwiftUI actually needs to construct the instance. On parent rebuilds where the view’s identity is preserved and the existing instance is reused, the expression is never invoked. The expensive initializer never fires.
@State is not autoclosure-wrapped. The Foo() initializer expression is eagerly evaluated every time the view’s init runs (which happens on every parent rebuild, even when the view’s identity is preserved and the existing instance is kept in storage). The Foo() allocation happens; SwiftUI throws away the new instance and continues using the stored one. For models with cheap init(), the wasted allocation is invisible. For models with expensive init() (network requests, large data load, async work kicked off in init), the difference is the difference between an app that works and an app that DDoSes its own backend on every parent rebuild.
The defensive pattern: keep model init() cheap so the difference doesn’t matter, or initialize the expensive model once at the app level and pass it down via .environment(). Models that need expensive setup work shouldn’t run that work in init regardless of which property wrapper holds them; lazy initialization or explicit setup methods are the right pattern for both @State and @StateObject cases.
withObservationTracking for Explicit Tracking
Outside SwiftUI, the tracking primitive is withObservationTracking { ... } onChange: { ... }6:
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // Triggers onChange
profile.email = "..." // Does NOT trigger onChange (we didn't read it)
The closure runs once and records every observable access. When any of those accesses’ source properties change, onChange fires exactly once (it’s a one-shot callback). To re-track, the closure must be set up again. The pattern is what SwiftUI uses internally to track view-body dependencies; for non-SwiftUI code (NSWindowController, Cocoa apps, command-line tools), withObservationTracking is the right primitive.
When ObservableObject Is Still The Right Call
Three cases where ObservableObject keeps its place:
Apps targeting iOS 16 and earlier. The Observation framework is iOS 17+. Apps with older deployment targets need ObservableObject. Once the deployment target moves to 17+, the migration is safe.
Models that need to publish notifications outside the value graph. ObservableObject’s objectWillChange is a Combine publisher; code that wants to subscribe to “any change” through Combine pipelines (debouncing, throttling, transforming the event stream) gets that for free with ObservableObject and would have to rebuild the equivalent with @Observable. The Observation framework prioritizes view re-evaluation efficiency over arbitrary publisher subscriptions.
Existing codebases where the migration cost outweighs the benefit. A working ObservableObject codebase that hasn’t measured a performance problem doesn’t gain enough from migration to justify the audit. Migrate when you’re already touching the file or when profiling identifies a hot spot.
For new code, on iOS 17+ targets, @Observable is the modern default and the migration path is clear.
What This Pattern Means For iOS 26+ Apps
Three takeaways.
-
Default to
@Observablefor new code. The macro is concise, the per-property tracking improves performance for common cases, and the migration vocabulary is clear. New models in iOS 17+ codebases should be@Observable. -
Audit
@StateObject→@Statemigrations for view identity. The swap compiles cleanly but can produce surprising re-initialization in views with conditional structure. Models that do expensiveinit()work need careful migration; models that don’t are safe. -
Use
@Bindabledeliberately. It’s the new pattern for two-way bindings into observable models. Reach for it in child views that need to mutate the parent’s model; keep the plain reference (var foo: Foo) for read-only views.
The full Apple Ecosystem cluster: typed App Intents; MCP servers; the routing question; Foundation Models; the runtime vs tooling LLM distinction; three surfaces; the single source of truth pattern; Two MCP Servers; hooks for Apple development; Live Activities; the watchOS runtime; SwiftUI internals; RealityKit’s spatial mental model; SwiftData schema discipline; Liquid Glass patterns; multi-platform shipping; the platform matrix; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; SF Pro typography; visionOS spatial patterns; Speech framework; SwiftData migrations; tvOS focus engine; what I refuse to write about. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.
FAQ
Why did Apple replace ObservableObject?
Two reasons. First, performance: ObservableObject’s objectWillChange publisher fires on every @Published mutation, triggering body re-evaluation on every dependent view regardless of whether the view actually reads the changed property. @Observable’s per-property tracking gates body re-evaluation on the property the view actually accesses. Second, syntax: the @Published annotation per property and the @StateObject/@ObservedObject/@EnvironmentObject ladder were verbose for what is conceptually one idea (“this is mutable shared state”). @Observable plus @State plus @Environment is shorter.
Does @Observable work with structs?
No. @Observable requires reference semantics; structs don’t qualify. The macro is for classes that hold mutable state across views. For value-type state in a single view, use @State directly with the value type.
Can I use @Observable and ObservableObject in the same app?
Yes. They coexist without conflict. A migration can proceed file-by-file. The boundary is per-type: a class is either ObservableObject or @Observable, not both, but different classes in the same app can use different approaches.
What about @Published properties that fire Combine pipelines?
@Observable does not provide a Combine publisher equivalent for individual properties. Code that uses $foo.publisher patterns from @Published properties needs to rebuild that subscription differently with @Observable (e.g., wrap the property in a value-type model and observe through SwiftUI’s update cycle, or use withObservationTracking repeatedly). For Combine-heavy code paths, the migration is real engineering work.
How does @Observable interact with SwiftData’s @Model?
@Model (SwiftData) types are automatically @Observable. The persistence framework adds Observable conformance as part of its codegen, so SwiftData models participate in the same per-property tracking as plain @Observable types. Views observing a @Model type’s properties get the same fine-grained re-evaluation behavior. The cluster’s SwiftData migrations and SwiftData schema discipline posts cover the persistence side of the same observation surface.
What’s @ObservationIgnored for?
It opts a stored property out of observation tracking. The macro normally rewrites every stored property to go through the registrar; properties marked @ObservationIgnored keep direct storage with no tracking. Use it for properties that shouldn’t trigger view re-evaluation: caches, file handles, metrics counters, the registrar itself.
References
-
Apple Developer Documentation: Observation framework. The framework reference covering the
Observableprotocol and the@Observablemacro. Available iOS 17+, macOS 14+, Swift 5.9+. ↩ -
Swift Evolution: SE-0395 Observability. The accepted Swift proposal with the design rationale, semantic requirements, and the registrar protocol contract. ↩
-
Apple Developer Documentation:
ObservationRegistrarandObservable. The runtime types the macro generates conformance to and the registrar API the synthesized accessors call. ↩↩↩ -
Apple Developer Documentation: Migrating from the Observable Object protocol to the Observable macro. Apple’s official migration guide covering the property-wrapper mapping table and SwiftUI integration changes. ↩
-
Apple Developer Documentation:
StateandStateObject. The two property wrappers’ documented initialization semantics around view identity and rebuild lifecycle. ↩ -
Apple Developer Documentation:
withObservationTracking(_:onChange:). The explicit tracking primitive used outside SwiftUI’s automatic view-body tracking. ↩