Skip to main content

SwiftUI Data Flow: Migrating from ObservableObject to @Observable Macro

 For years, SwiftUI data flow relied heavily on the Combine framework. We implemented ObservableObject, marked properties with @Published, and injected dependencies via @StateObject or @EnvironmentObject. While functional, this approach introduced a hidden performance tax: over-invalidation.

With Swift 5.9 and iOS 17, Apple introduced the Observation framework and the @Observable macro. This is not just syntactic sugar; it is a fundamental shift in how data dependency is tracked, moving from an object-level invalidation model to a field-level access tracking model.

The Root Cause: The objectWillChange Bottleneck

To understand why the migration is necessary, we must analyze the inefficiency of ObservableObject.

When you conform a class to ObservableObject, the compiler synthesizes an objectWillChange publisher (unless you define one). Every time a property marked with @Published is mutated, this publisher fires.

SwiftUI subscribes to this publisher. The critical flaw is the granularity:

  1. Broadcasting: If your ViewModel has 20 properties and you update oneobjectWillChange fires.
  2. Invalidation: Any View observing this object receives the signal that the object changed.
  3. Redundant Computation: SwiftUI re-evaluates the body of the observing View to calculate the diff, even if that View only renders a property that did not change.

In complex dashboards or lists where cells observe large data models, this results in massive over-computation of view bodies. The @Observable macro solves this by using the Swift Observation framework to track specific property access during the render loop. If a View reads user.name, it subscribes only to changes in name, ignoring mutations to user.age.

The Fix: Migration Strategy

We will refactor a standard User Settings feature from the legacy Combine approach to the new Observation protocol.

Phase 1: The Legacy Code (ObservableObject)

Here is a typical iOS 16 implementation. Note the reliance on property wrappers and Combine protocols.

import SwiftUI
import Combine

class UserSettingsLegacy: ObservableObject {
    @Published var username: String = "DevOne"
    @Published var themeColor: String = "Blue"
    @Published var notificationCount: Int = 5
    
    // Computed property relying on published properties
    var statusMessage: String {
        "Welcome \(username), you have \(notificationCount) alerts."
    }
    
    func reset() {
        notificationCount = 0
    }
}

struct LegacyDashboardView: View {
    // 1. Explicit ownership wrapper
    @StateObject private var settings = UserSettingsLegacy()
    
    var body: some View {
        VStack {
            // 2. This view reads 'username' and 'notificationCount'
            Text(settings.statusMessage)
            
            Button("Clear Notifications") {
                settings.reset()
            }
            
            // 3. Problem: Changing 'themeColor' elsewhere would trigger 
            // a body re-evaluation here, even though this view doesn't use it.
            ThemeIndicator(colorName: settings.themeColor)
        }
    }
}

struct ThemeIndicator: View {
    let colorName: String
    var body: some View {
        Text("Theme: \(colorName)")
    }
}

Phase 2: Refactoring to @Observable

To migrate this to iOS 17 standards, we remove the Combine overhead. The changes involve the model definition, the state ownership, and environment injection.

1. Update the Model

We replace ObservableObject with the @Observable macro and remove @Published. The macro automatically expands the class to support observation for all stored properties.

import SwiftUI
import Observation

@Observable
class UserSettings {
    // No @Published needed. All stored properties are observable by default.
    var username: String = "DevOne"
    var themeColor: String = "Blue"
    var notificationCount: Int = 5
    
    // Computed properties are automatically tracked based on the 
    // properties they access (Observation tracks the dependency graph).
    var statusMessage: String {
        "Welcome \(username), you have \(notificationCount) alerts."
    }
    
    // Properties ignored by observation can be marked explicitly
    @ObservationIgnored var loggerID: String = UUID().uuidString
    
    func reset() {
        notificationCount = 0
    }
}

2. Update the View

The view modifiers change significantly. @StateObject@ObservedObject, and @EnvironmentObject are largely deprecated for @Observable types.

  • Ownership: Use @State instead of @StateObject.
  • Observation: Use standard let or var for dependencies passed in. No @ObservedObject required.
  • Environment: Use the new type-based injection.
struct ModernDashboardView: View {
    // 1. @State manages the lifecycle of reference types marked with @Observable
    @State private var settings = UserSettings()
    
    var body: some View {
        VStack {
            // 2. Granular tracking occurs here. 
            // If 'themeColor' changes, THIS view does not invalidate.
            Text(settings.statusMessage)
            
            Button("Clear Notifications") {
                settings.reset()
            }
            
            // Passing the instance down. 
            // ThemeIndicator will observe 'themeColor' specifically.
            ThemeIndicator(settings: settings)
        }
    }
}

struct ThemeIndicator: View {
    // 3. No @ObservedObject needed. 
    // Just a plain dependency. The view will still update.
    let settings: UserSettings
    
    var body: some View {
        // Accessing 'settings.themeColor' registers a dependency 
        // for this specific View instance.
        Text("Theme: \(settings.themeColor)")
    }
}

3. Handling Environment Injection

The syntax for Environment objects has shifted from key-paths or string-based matching to type-safe keys.

The Parent:

@main
struct MyApp: App {
    @State private var settings = UserSettings()

    var body: some Scene {
        WindowGroup {
            ModernDashboardView()
                // Inject the instance directly by type
                .environment(settings)
        }
    }
}

The Child:

struct DeepChildView: View {
    // Read directly from the environment using the type
    @Environment(UserSettings.self) var settings
    
    var body: some View {
        // If settings is not in the environment, this crashes at runtime 
        // (similar to EnvironmentObject). Use Optional for safety if needed.
        Toggle("Private Mode", isOn: Bindable(settings).isPrivate)
    }
}

Note: We used Bindable(settings) above. The @Observable macro does not project bindings ($settings.isPrivate) automatically when the property is not @State. To create bindings for an observable object passed as a parameter or environment, wrap it in Bindable.

Technical Deep Dive: Why It Works

The magic of @Observable lies in how it instruments your code during compilation.

1. Macro Expansion

When you annotate a class with @Observable, the Swift compiler expands the code to include a _$observationRegistrar. Every stored property is rewritten into a computed property.

The getter is instrumented to register access:

get {
    _$observationRegistrar.access(self, keyPath: \.username)
    return _username
}

The setter is instrumented to notify observers:

set {
    _$observationRegistrar.withMutation(of: self, keyPath: \.username) {
        _username = newValue
    }
}

2. withObservationTracking

SwiftUI wraps the execution of a View's body inside a global generic function called withObservationTracking.

  1. SwiftUI starts evaluating body.
  2. It installs a "tracking pointer" on the current thread.
  3. As your View reads properties (e.g., settings.username), the instrumented getters fire.
  4. The ObservationRegistrar notes that the currently rendering View depends on the \.username keypath.
  5. When body finishes, the tracking pointer is removed.

3. The DAG (Directed Acyclic Graph)

SwiftUI now possesses a precise dependency graph. It knows that ModernDashboardView depends on settings.username and settings.notificationCount.

When settings.themeColor is mutated:

  1. The withMutation block fires.
  2. The registrar looks up subscribers for \.themeColor.
  3. It sees ThemeIndicator in the list, but not ModernDashboardView.
  4. Only ThemeIndicator is flagged for redraw.

Conclusion

Migrating to @Observable removes the boilerplate of Combine publishers and eliminates the performance pitfalls of object-wide invalidation. By treating data dependencies as granular access records rather than broad object subscriptions, your SwiftUI applications on iOS 17 become inherently more performant and easier to reason about.

Start by stripping @Published from your models, switch @StateObject to @State, and let the compiler handle the dependency tracking.