Mobile & Machine

SwiftUI Observable Macro vs ObservableObject in Production

The macro eliminates wasteful full-object redraws by tracking property access at the compiler level.

Senior Writer · · 9 min read
Cover illustration for “SwiftUI Observable Macro vs ObservableObject in Production”
Swift Architecture · September 23, 2026 · 9 min read · 2,112 words

ObservableObject didn't fail because Apple built it carelessly. It failed because it borrowed Combine's plumbing to solve a problem Combine was never designed to solve at the view level, and that mismatch cost production apps real CPU cycles across several years of SwiftUI development. The macro, introduced at WWDC 2023 alongside iOS 17 and now the default recommendation for anything targeting iOS 17 or later, fixes the mismatch at the compiler level rather than papering over it with more property wrappers. That distinction, compiler-level fix versus a workaround built on top of it, is why this migration deserves to be taken seriously rather than filed under "nice to have.""

Under the old model, any mutation to any @Published property invalidated every view subscribed to that object, even if the view never actually read the property that changed. A settings screen with twelve toggles and one @Published var isDarkMode: Bool would redraw the entire form when a user flipped a switch that had nothing to do with most of what was on screen. Layered on top of that came the property wrapper sprawl: @Published for the model, @StateObject or @ObservedObject depending on who owned the object's lifetime, @EnvironmentObject for anything injected from above. Each wrapper solved one slice of a single underlying problem, tracking state and tracking lifetime, and mixing them up was an easy, well-documented way to introduce bugs (use @StateObject at the point of creation, @ObservedObject everywhere else, and get it backwards, and the object silently re-initializes on every parent redraw). None of this was optional complexity, either. ObservableObject required Combine under the hood even when a given screen didn't need Combine's publisher/subscriber machinery, which made it structurally hard to limit what a view actually observed.

What the @Observable macro does at compile time

@Observable @Observable is a code transformation: the macro rewrites the class at expansion time, and the compiler does real work you can inspect directly in Xcode by right-clicking the macro and choosing Expand Macro. It's a code transformation: the macro rewrites the class at expansion time, and the compiler does real work you can inspect directly in Xcode by right-clicking the macro and choosing Expand Macro.

Two things happen during that expansion. First, the class picks up conformance to the Observable marker protocol, which has no runtime representation and no requirements of its own. It exists purely as a compiler signal, telling SwiftUI "this type participates in the new tracking system." Second, every stored property gets rewritten as a computed property tracked by @ObservationTracked, backed by a private storage variable behind the scenes, and this is what actually does the work.

The engine driving all of it is called the ObservationRegistrar. It records which properties a given view reads while that view's body executes, and when a property mutates, it notifies only the views that read it, not every view subscribed to the object. A pair of methods, access(keyPath:) and withMutation(keyPath:), hooks property reads and writes directly into the registrar, producing the per-property observation described next. The rest of this piece is about how observation in @Observable happens per-property, not per-object, and that single architectural shift.

How the measurable rendering gains compound

Put concretely: change a name property, and only the TextField bound to name redraws. Changing age elsewhere on the same object updates only the Text displaying age. The TextField never gets touched.

Independent benchmarking cited in developer research has put the gains at 40 to 60 percent better performance in large, complex views, with form-heavy screens seeing 20 to 30 percent fewer view redraws and corresponding drops in CPU usage and frame time. Those aren't marginal numbers for a mobile app where the frame budget is only a matter of milliseconds and every dropped frame is visible as stutter.

The gains compound hardest in large lists. Under ObservableObject, a shared model object driving fifty rows meant any property change on that object could trigger a redraw pass across every row bound to it, whether or not that row's data changed. Under @Observable, each row only re-renders when the specific property it reads changes. Nested objects benefit too: with @Observable, a nested observable object gets tracked automatically at the property level, so a change three layers deep in an object graph doesn't require manually propagating a signal up through a parent ObservableObject the way the old pattern demanded. That manual propagation work was a common source of app-specific plumbing code that existed only to compensate for the framework's coarse invalidation granularity. It's gone now, not patched.

The initialization trap that makes @Observable a dangerous drop-in replacement

Teams get hurt if they treat this as a find-and-replace exercise. @StateObject takes its initial value through an @autoclosure and guarantees that closure runs exactly once for the lifetime of the view. @State, by contrast, takes its value directly, and SwiftUI can call that initializer every single time it decides to rebuild the view struct, which happens far more often than most developers assume.

What SwiftUI actually does under the hood when a view rebuilds: it initializes new @State variables, runs the view's initializer, and then resets those @State variables back to the previously stored objects, discarding whatever new instances it just created. Under most circumstances that's invisible and harmless. The discarded instances aren't wired to anything, so nobody notices they briefly existed.

The trouble is that "discarded" doesn't mean "immediately deallocated." In practice, discarded instances are not always immediately deallocated, and that lingering can be confirmed by inspecting the memory graph in Xcode's debugger. Three coding patterns turn that lingering into an actual production bug rather than a theoretical curiosity. Declaring the model inside a root View rather than at the App level is the first, because it puts the object's construction inside a struct that SwiftUI is free to re-initialize on a whim. Reading from UserDefaults at init time is the second, since a discarded-but-not-deallocated instance may still have run that read, doing real work for an object that was never meant to exist. Registering for lifecycle notifications at init time is the third, and arguably the most dangerous, because a phantom instance sitting in memory with a live notification observer attached can fire logic the developer never intended to run twice, or run even once.

None of that happens with @StateObject, because the single-initialization guarantee prevents it categorically. Swap @StateObject for @State during a migration to @Observable without auditing for these three patterns, and the bug doesn't appear in a compiler warning. It appears in production, weeks later, as a memory graph nobody thought to pull until users started reporting odd behavior.

What Swift 6 strict concurrency means for every @Observable view model

Swift 6 shipped in September 2024, and the change most consequential for iOS teams was stricter @MainActor enforcement. The compiler now flags UI-driving code that isn't explicitly marked as running on the main actor, closing off a category of data race that used to compile cleanly and fail silently at runtime.

The ObservationRegistrar itself is thread-safe internally. That's not the issue. SwiftUI still expects the state driving its view hierarchy to be updated on the main thread, and under Swift 6's strict concurrency checking, the practical rule is straightforward: mark view models with @MainActor, full stop, especially if that view model touches anything SwiftUI renders directly.

There's a genuine convenience buried in this requirement, too. A type annotated @MainActor gains clearer isolation guarantees, which can reduce the annotation noise developers would otherwise have to add by hand, and cuts down on the annotation noise that made early Swift 6 adoption feel tedious. Swift 6.2 goes further: setting the defaultIsolation build setting to MainActor makes an entire module run on the main actor by default, so individual views and view models no longer need the annotation spelled out one by one. For teams that found Swift 6's concurrency checking punishing in its first release, 6.2's default-isolation setting is the release that actually makes strict concurrency livable at scale.

The mechanical steps to migrate an ObservableObject store, and when to hold off on migrating

The mechanics of migrating a single store are genuinely fast, on the order of 10 to 20 minutes for a moderately sized class. Remove @Published from every property. Add @Observable to the class declaration. Drop the ObservableObject protocol conformance, since the macro no longer needs it. At each call site, swap @ObservedObject for @Bindable. At the instantiation site, replace @StateObject with @State, and this is the step that demands the audit described above, not a rubber stamp.

The environment pattern changes too. .environmentObject(model) becomes .environment(model), and @EnvironmentObject becomes @Environment(DataModel.self), reading the type directly out of the environment rather than relying on a separately keyed object graph. Model instantiation should live at the App-level body, not inside a View struct, precisely to sidestep the initialization trap.

Migrating everything at once is not always the right call, though, and pretending otherwise does teams a disservice. Delay adoption if the codebase is mid-migration to SwiftUI itself, or mid-migration to async/await: stacking migrations makes it much harder to isolate which change caused a given regression. Hold off if there's substantial Objective-C bridging in the object graph that isn't ready to be touched, since @Observable's guarantees don't extend cleanly across that boundary. Hold off during compressed release windows, App Store submission freezes, or the days surrounding a major OS launch, when the cost of a subtle regression is highest and the time to properly test it is lowest. And if the app still needs to support iOS 16, @Observable simply isn't available there; the workable pattern is @Observable behind #if available, with an ObservableObject fallback carrying the older deployment target.

How @Observable fits into production MVVM architecture in 2026

iOS developers are actively disputing whether @Observable, paired with SwiftUI's struct-based views, removes the need for a separate ViewModel layer. Some practitioners argue the View struct itself can now hold enough reactive state to make a dedicated ViewModel redundant for simple screens. Others maintain that MVVM remains the right structure the moment business logic or complex state enters the picture, regardless of how lightweight the observation mechanism underneath has become.

The practical consensus forming around new projects in 2026 splits the difference without pretending the debate is settled: MVVM plus Coordinators plus dependency injection, written in Swift 6, targeting iOS 17 and above, with @Observable doing the reactive heavy lifting that Combine used to handle. Most new screens, under this pattern, need nothing more than async/await and @Observable. No Combine pipeline, no @Published annotation layer, no manual object graph wiring through @EnvironmentObject.

The underlying argument for separating business logic from view code in the first place remains. A ViewModel under this newer architecture is thinner and quieter than its predecessor from an earlier framework generation, carrying less boilerplate and fewer annotations, but it still earns its place through testability: logic that lives outside a View struct can be unit tested without spinning up SwiftUI's rendering pipeline.

Building AI-native iOS features on @Observable: the architecture that works

Apple's on-device AI stack, which includes the Foundation Models framework for on-device inference with no server round-trip, was built for this language from the start. These are not APIs bolted onto Objective-C or exposed through some cross-platform bridge; they are designed to work alongside Swift's structured concurrency model.

The @Generable macro is the clearest expression of that design choice. Developers define an ordinary Swift struct, say a WorkoutPlan, and the on-device model generates that struct directly as a typed object rather than returning a blob of text the app then has to parse, validate, and hope holds together. SwiftUI renders the result with full type safety intact, because the model's output was never untyped text to begin with.

Native Tool Calling lets the model reach into local databases or call app functions directly, which is what turns a chat feature into something closer to a genuine agent operating at the view model layer. The recommended production pattern threads all of this through a single structure: an @Observable, @MainActor view model that owns an LLMClient, publishes streaming tokens into a state array as they arrive, and cancels its running Task the moment the view disappears. Swift 6's strict concurrency checking is what makes that pattern safe to ship rather than a source of intermittent, hard-to-reproduce crashes: the compiler enforces the main-actor boundary the UI needs, while the Task-per-view lifecycle keeps a background generation from writing into state nobody is watching anymore. That combination, typed generation on one side and auditable concurrency on the other, is the actual foundation on-device AI features are being built on, a deliberately structured architecture rather than an ad hoc set of async calls wired up after the fact.

Sources

  1. @Observable Macro performance increase over ObservableObject
  2. SwiftUI
  3. @Observable Macro: SwiftUI Guide 2026
  4. @Observable vs ObservableObject
  5. SwiftUI: Observable macro under the hood | nsvasilev.com
  6. swift-evolution/proposals/0395-observability.md at main · swiftlang/swift-evolution
  7. developer.apple.com

More in Swift Architecture