Objectively Better, Observably Trickier
The Observation Framework is great... once you figure out how it actually works
Let’s be honest: the Observation Framework and @Observable macro isn’t brand new. It has been navigating our waters for a few years now, offering a concurrency-safe upgrade to the older, more mysterious ObservableObject. But while many of us have been slowly adopting it, others have clung to the familiar comforts of Combine and @Published.
However, the recent release of Xcode 26.3 made the situation crystal clear.
With the new update, Apple introduced built-in “Agentic Coding” and an exposed Model Context Protocol (MCP) to drive it. Curious developers quickly cracked open the internal system prompts that guide Apple’s own AI assistant. Hidden inside the AgentSystemPromptAddition was this directive:
“- Architecture: Follow SwiftUI patterns with clear separation of concerns. Avoid using the Combine framework and instead prefer to use Swift’s async and await versions of APIs instead.”
There is no ambiguity left. Apple is explicitly instructing its own intelligence to “Avoid the Combine framework.” The era of Combine in SwiftUI is officially coming to a close. We are even seeing the Observation framework mature further with recent enhancements like the Observations type, giving us even more granular control.
But here is the catch: If you treat Observation like a simple find-and-replace for Combine, you are going to crash.
You delete your @Published wrappers, the compiler applauds, but then you tap a button and the UI doesn’t flinch. You aren’t crazy, and the API isn’t broken. You are just experiencing the friction of a fundamental paradigm shift.
We have moved from a Push model (where objects shout “I changed!” to anyone listening) to a Pull model (where Views quietly and efficiently track only what they touch). It is a smarter, leaner way to build apps, but it requires us to embrace a new mental model.
So, let’s clear the fog. Here are a few tips and tricks to help navigate the transition, avoid the common traps, and fully unlock the power of the new observation engine.
1. The “Lazy Init” Trap: Your View is Leaking Performance
In the “Old World,” @StateObject was our magic shield. It used an @autoclosure to ensure your ViewModel was only instantiated once, effectively acting lazily.
In the “New World,” Apple tells us to use @State to hold our reference types.
// The New Way (Potential Trap)
@State private var viewModel = HeavyViewModel()
Here lies the trap. Because State does not use an autoclosure for its initial value, the Swift runtime executes HeavyViewModel() every single time the parent view’s body is recomputed.
Yes, SwiftUI is smart enough to discard the new instance and keep using the old one. But the damage is done:
CPU Waste: You are allocating and deallocating a class instance on every frame refresh.
Side Effect Hazards: If your
init()fires off a network request or heavy setup, your app will stutter or trigger duplicate logic.
The Fix: Defer Creation with .task
Since we don’t have a State(lazy:) option, Apple’s official guidance is to make the state optional and hydrate it using the .task modifier.
struct ContentView: View {
@State private var viewModel: HeavyViewModel?
var body: some View {
HomeView(model: viewModel)
.task {
// Only runs once when the view first appears
if viewModel == nil {
viewModel = HeavyViewModel()
}
}
}
}
Why .task? It runs immediately before the view appears and, unlike onAppear, automatically handles the lifecycle of any async work if you decide to load data during initialization.
2. The “Nested Observable” Dead Zone
This is where 90% of developers get stuck. They migrate their code, the compiler is happy, but the UI simply stops updating.
The Pitfall:
You have a parent model containing a child model, both marked with @Observable.
@Observable class User {
var settings = Settings()
}
@Observable class Settings {
var isDarkMode = false
}
If you access user.settings.isDarkMode inside a view that only explicitly observes user, you might miss updates. If you pass user to a child view, and that child view only reads user.settings (the reference), the parent view has no idea that the internal properties of settings changed.
The Fix: Granular Access or @Bindable
You must ensure that the specific View that needs to update is the one reading the specific property.
The Trap (Lazy Observation)
struct UserProfile: View {
@State var user = User()
var body: some View {
VStack {
// Potential Issue: We are digging deep into the graph.
// If 'isDarkMode' changes, does UserProfile need to redraw?
// It relies on implicit tracking that can be fragile in complex views.
Toggle("Dark Mode", isOn: Bindable(user.settings).isDarkMode)
}
}
}
The Solution (Granular Passing)
Pass the specific node of the graph to the view that needs it. This ensures the “Spotlight” is shining exactly where the data changes.
struct UserProfile: View {
@State var user = User()
var body: some View {
VStack {
Text("User: \(user.name)")
// FIX: Pass ONLY the child object to a dedicated view.
SettingsEditor(settings: user.settings)
}
}
}
struct SettingsEditor: View {
// 1. We accept the Observable class instance directly
@Bindable var settings: Settings
var body: some View {
// 2. We bind directly to the property on this scoped object.
// When 'isDarkMode' changes, ONLY 'SettingsEditor' redraws.
Toggle("Dark Mode", isOn: $settings.isDarkMode)
}
}
3. The “Array Mutation” Ghost
You have a list of items, you toggle a property on one of them, and… nothing happens.
The “Old World” Habit:
We used to rely on List($viewModel.items) to get bindings. In the @Observable world, if you try to iterate and bind, you hit a syntax wall because the iterator gives you a let constant, not a binding.
List(viewModel.items) { item in
// ERROR: Cannot find '$item' in scope
Toggle(item.title, isOn: $item.isDone)
}
The Fix: Shadowing with @Bindable
This is the new “magic spell” you need to memorize. You must create a @Bindable shadow variable inside the scope where you need the binding.
List(viewModel.items) { item in
// 1. Create a bindable shadow copy
@Bindable var item = item
// 2. Now you can access the projected value ($)
Toggle(item.title, isOn: $item.isDone)
}
The @Bindable property wrapper creates a bridge. It tells SwiftUI: “I know this object (item) is @Observable. Please create dynamic bindings for its properties right here, right now.”
This isn't just a hack; it is the official pattern prescribed by Apple. As noted in the documentation for Bindable, you must create a bindable value within the view's scope to generate bindings for an observable object that you don't own (like one passed into a closure).
4. The “Computed Property” Compiler Error
You have a favorite property wrapper—maybe @AppStorage—and you drop it into your new @Observable class.
@Observable class GameScore {
// ERROR: Property wrapper cannot be applied to a computed property
@AppStorage("highScore") var score: Int = 0
}
This fails because @Observable converts all stored properties into computed properties behind the scenes to inject its tracking logic. Property wrappers generally don’t like wrapping computed properties.
The Captain’s Advice: The Service Pattern
You can fix this by writing complex boilerplate involving @ObservationIgnored (check out this solution by David Steppenbeck and how it works inside). But if you find yourself fighting the compiler to keep @AppStorage inside your observable class, pause for a moment.
Perhaps it’s worth considering that you may need to let go of the wrapper convenience and go back to more traditional means (e.g. using UserDefaults). Plus, the @Observable era is the perfect time to separate Data from Persistence.
Instead of coupling your data model tightly to UserDefaults, consider moving that responsibility to a dedicated Service or Manager.
The Service: Handles the saving/loading logic.
The Observable Model: Simply holds the current source of truth in a standard Swift type.
This keeps your observation logic clean (no manual wiring required!) and your persistence logic isolated where it belongs.
Conclusion: Finding Your Sea Legs
Migrating to @Observable is a bit like upgrading your ship’s engine from diesel to nuclear fusion. The potential for power and efficiency is incredible, but if you wire it up using the old schematics, you’re going to have a meltdown.
The most important thing to remember is the “Spotlight Rule”: The new observation system is lazy. It only updates a view if that view is currently “shining a spotlight” on the specific property that changed. If your view stops looking—even for a second, or because of a nested object—the updates stop coming.
Once you master the lazy .task initialization, strict granular access for child objects, and the @Bindable shadow syntax, you’ll find that the new system isn’t just “Objectively Better”—it’s actually fun to use.
Until then, keep your eye on the graph, and trust your bindings.




Great breakdown, thanks Danny
Excellent breakdown of the paradigm shift. The spotlight rule is such a good mental model, tho I've been bitten by the nested observable issue more times than I'd like to admit. That Xcode system prompt revelation basically confirms what many suspected about Apple deprecating Combine. I ran into the lazy init trap last month and had to rewrite an entire view heirarchy.