The Observation Framework: How `@Observable` Replaced `ObservableObject`


ObservableObject and @Published worked. They shipped real apps. But they had a dirty secret: changing any @Published property triggered a redraw of every view that held a reference to the object — even views that only cared about an unrelated property. The Observation framework, introduced in iOS 17, fixes this at the compiler level.

This post covers @Observable, how it achieves fine-grained observation, how to migrate from ObservableObject, and when the new model makes the most difference. It does not cover SwiftData’s @Model macro (though the two share the same underlying machinery) — that topic has its own post.

Note: The Observation framework requires iOS 17+ / macOS 14+. All code in this post uses Swift 6 strict concurrency. If your app supports earlier OS versions, see When to Use (and When Not To) for a compatibility strategy.

Contents

The Problem

Consider a simple ObservableObject model for a Pixar film catalog app. The store holds a list of films, a loading flag, and the currently selected film:

// ObservableObject — the pre-iOS 17 approach
final class FilmStore: ObservableObject {
    @Published var films: [PixarFilm] = []
    @Published var isLoading: Bool = false
    @Published var selectedFilm: PixarFilm?
}

Now imagine two separate views consuming this store:

struct FilmListView: View {
    @ObservedObject var store: FilmStore

    var body: some View {
        List(store.films) { film in
            Text(film.title)
        }
    }
}

struct LoadingIndicatorView: View {
    @ObservedObject var store: FilmStore

    var body: some View {
        if store.isLoading {
            ProgressView("Loading Pixar catalog…")
        }
    }
}

Both views hold an @ObservedObject reference to the same FilmStore. When isLoading flips to true, FilmListView re-evaluates its body — even though it never reads isLoading. SwiftUI compares the new body output against the previous one and skips the actual layout update when the output is identical, but the body property itself still runs. In a List with 50 film cells, that’s 50 identity comparisons on every loading state change.

The root cause is structural: ObservableObject fires a single objectWillChange notification on the object, not on the property. Every subscriber — every @ObservedObject, every @EnvironmentObject — receives the notification and invalidates. There is no mechanism to say “I only care about films; skip me when isLoading changes.”

You can profile this behavior in Instruments using the SwiftUI instrument. Filter by body invocations and watch how many views re-render during a simple loading state toggle. In a moderately complex screen, it’s common to see 10–20 unnecessary body evaluations per property change.

The Observation Framework

Apple Docs: Observation — Swift Standard Library (Swift 5.9 / iOS 17)

SE-0395 introduced the Observation module. At its core is the @Observable macro. Apply it to a class and you get property-level tracking for free:

import Observation

@Observable @MainActor
final class FilmStore {
    var films: [PixarFilm] = []
    var isLoading: Bool = false
    var selectedFilm: PixarFilm?
}

No @Published. No ObservableObject conformance. The macro expands to code that wraps each stored property with access tracking via _$observationRegistrar. When SwiftUI evaluates a body, it records which properties were accessed. The next time a property changes, only the views that actually read that property are invalidated.

Here is a simplified view of what the macro generates (abbreviated for clarity):

// What @Observable expands to — simplified for clarity
final class FilmStore: Observable {
    private var _films: [PixarFilm] = []
    private var _isLoading: Bool = false
    private let _$observationRegistrar = ObservationRegistrar()

    var films: [PixarFilm] {
        get {
            _$observationRegistrar.access(self, keyPath: \.films)
            return _films
        }
        set {
            _$observationRegistrar.withMutation(self, keyPath: \.films) {
                _films = newValue
            }
        }
    }

    // isLoading and selectedFilm follow the same pattern
}

SwiftUI calls _$observationRegistrar.access(self, keyPath:) as your body runs. The framework builds a dependency set for each view. When a mutation happens, only views whose dependency set includes that keypath are scheduled for re-evaluation.

Using @Observable in Views

With @Observable, the property wrappers simplify considerably:

Old (ObservableObject)New (@Observable)
@StateObject@State
@ObservedObjectPlain property (no wrapper)
@EnvironmentObject@Environment with a key path
Binding via $@Bindable + $

The LoadingIndicatorView from the problem section becomes:

struct LoadingIndicatorView: View {
    var store: FilmStore // No property wrapper needed

    var body: some View {
        if store.isLoading {
            ProgressView("Loading Pixar catalog…")
        }
    }
}

And FilmListView becomes:

struct FilmListView: View {
    var store: FilmStore // Only reads store.films

    var body: some View {
        List(store.films) { film in
            Text(film.title)
        }
    }
}

Now when isLoading changes, FilmListView.body does not re-run. SwiftUI knows FilmListView only accessed films, so it’s not in the dependency set for isLoading. This is fine-grained observation in practice.

@Bindable for Two-Way Bindings

Apple Docs: @Bindable — SwiftUI

When a view needs to write back to an @Observable property — a text field, a toggle, a slider — use @Bindable to produce a Binding<T>:

struct FilmDetailView: View {
    @Bindable var store: FilmStore

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("Search films", text: $store.searchQuery) // Two-way binding
            Toggle("Show favorites only", isOn: $store.showFavoritesOnly)
            Text("Results: \(store.films.count)")
        }
        .padding()
    }
}

@Bindable does not observe the model — it only vends bindings. The observation tracking still happens through the property getter, so SwiftUI invalidates the view only for properties it reads in body.

Passing @Observable Through the Environment

The old @EnvironmentObject required conformance to ObservableObject. With @Observable, you inject and retrieve models through the standard @Environment mechanism using a value key path:

// Injection at the top of your view hierarchy
@main
struct PixarCatalogApp: App {
    @State private var filmStore = FilmStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(filmStore) // Shorthand for @Observable models
        }
    }
}

// Retrieval in any descendant view
struct FilmGridView: View {
    @Environment(FilmStore.self) private var store

    var body: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))]) {
            ForEach(store.films) { film in
                FilmCard(film: film)
            }
        }
    }
}

Warning: If you forget to inject the model with .environment(_:) and a descendant view has @Environment(FilmStore.self), your app will crash at runtime with a missing environment value error. There is no compile-time safety here — unlike the key-path approach for custom EnvironmentValues. Always inject at the scene root.

Migrating from ObservableObject

Migration is mechanical and can be done incrementally — you can have both ObservableObject and @Observable classes in the same codebase.

Step 1 — Update the model class:

// Before
final class FilmStore: ObservableObject {
    @Published var films: [PixarFilm] = []
    @Published var isLoading: Bool = false
    @Published var selectedFilm: PixarFilm?
}

// After
@Observable @MainActor
final class FilmStore {
    var films: [PixarFilm] = []
    var isLoading: Bool = false
    var selectedFilm: PixarFilm?
}

Step 2 — Update property wrappers at the call site:

// Before — owning view
@StateObject private var store = FilmStore()

// After — owning view
@State private var store = FilmStore()

// Before — receiving view
@ObservedObject var store: FilmStore

// After — receiving view (no wrapper)
var store: FilmStore

// Before — environment
@EnvironmentObject var store: FilmStore

// After — environment
@Environment(FilmStore.self) private var store

Step 3 — Update bindings:

// Before
@ObservedObject var store: FilmStore
TextField("Title", text: $store.searchQuery)

// After
@Bindable var store: FilmStore
TextField("Title", text: $store.searchQuery)

The $ syntax still works — @Bindable is the mechanism that enables it for @Observable types.

Tip: Migrate one model class at a time. Because ObservableObject and @Observable coexist, you can migrate FilmStore today and tackle CharacterStore next sprint. Both will work correctly in the same view hierarchy.

Advanced Usage

withObservationTracking

withObservationTracking(_:onChange:) lets you observe @Observable models outside of SwiftUI — useful in plain Swift code, UIKit view controllers, or background tasks:

@available(iOS 17, *)
func observeFilmCount(store: FilmStore) {
    withObservationTracking {
        // Access properties here to register dependencies
        print("Current film count: \(store.films.count)")
    } onChange: {
        // Called when any accessed property changes
        // Note: This fires once, not repeatedly — re-register inside onChange
        observeFilmCount(store: store)
    }
}

The callback fires once per dependency cycle. You are responsible for re-registering inside onChange if you want continuous observation. This is intentional — it gives you control over observation lifetime and avoids runaway recursion.

@Observable vs @Model (SwiftData)

SwiftData’s @Model macro is built on top of the same Observation machinery as @Observable. The key difference is persistence: @Model types are backed by a SwiftData store; @Observable types live only in memory. You cannot use @Observable on a @Model class — @Model already subsumes @Observable and adds its own persistence tracking on top.

// @Model includes @Observable behavior automatically
@Model
final class PixarFilm {
    var title: String
    var releaseYear: Int
    var studio: String = "Pixar"

    init(title: String, releaseYear: Int) {
        self.title = title
        self.releaseYear = releaseYear
    }
}

Ignoring Properties

Not every stored property should trigger observation. Mark a property with @ObservationIgnored to exclude it from the tracking system — useful for caches, loggers, or non-UI state:

@Observable @MainActor
final class FilmStore {
    var films: [PixarFilm] = []
    var isLoading: Bool = false

    @ObservationIgnored
    private var cache: [String: PixarFilm] = [:] // Changes here won't trigger views

    @ObservationIgnored
    private let logger = Logger(subsystem: "io.cocoabytes", category: "FilmStore")
}

Performance Considerations

The headline benefit is fewer body evaluations. In a benchmark screen with a single ObservableObject holding 5 properties and 8 observing views, a change to one property triggers all 8 body calls under the old model. With @Observable, only the views that read that specific property re-evaluate — in a typical distribution, 1–3 views rather than 8.

That said, there are a few subtleties:

Computed properties are not automatically tracked. If a view reads a computed property backed by stored state, tracking works correctly through the getter chain. But if a computed property depends on external state (a UserDefaults value, a file on disk), @Observable will not react to those changes. You need to model that state explicitly as a stored property.

Collections trigger on any mutation. If films is a [PixarFilm] array and you append a single film, all views reading store.films re-evaluate — including those that only display store.films.count. Consider exposing fine-grained properties (var filmCount: Int { films.count }) if separate views care about different aspects of the same collection.

Heap allocation. @Observable classes are reference types. Each FilmStore instance lives on the heap. The observation registrar adds a small overhead per instance — negligible in practice, but relevant if you create thousands of @Observable objects.

For profiling, open Instruments, select the SwiftUI template, and filter the View Body track by your view type. Compare body invocation counts before and after migration. WWDC 2023 Session 10149, “Discover Observation in SwiftUI,” includes Instruments screenshots demonstrating the improvement.

Apple Docs: ObservationRegistrar — Observation

When to Use (and When Not To)

ScenarioRecommendation
New project targeting iOS 17+Use @Observable exclusively. No reason to reach for ObservableObject.
Existing app, iOS 16 minimumKeep ObservableObject for shared models. Use @Observable for new screens.
Existing app, iOS 15 minimumStay on ObservableObject. Migration cost outweighs the benefit at iOS 15.
Model shared across SwiftUI and UIKitUse @Observable with withObservationTracking in UIKit contexts.
SwiftData modelsUse @Model. It includes @Observable behavior. Do not apply @Observable yourself.
Class with 1–2 properties, 1–2 viewsPerformance difference is marginal. Use @Observable for consistency.
Computed-only model (no stored state)Neither applies. Use a struct with @State at the call site.

Summary

  • ObservableObject fires a single objectWillChange notification for any property change, invalidating all observing views regardless of which property they read.
  • @Observable tracks observation at the property level: only views that accessed a specific keypath re-evaluate when that keypath mutates.
  • Migration is mechanical — remove @Published, replace @StateObject with @State, drop @ObservedObject wrappers, and use @Bindable for two-way bindings.
  • Use @ObservationIgnored to exclude properties (caches, loggers) from tracking.
  • withObservationTracking enables @Observable consumption outside of SwiftUI.
  • @Model (SwiftData) subsumes @Observable — do not apply both.

The Observation framework pairs naturally with structured data flow patterns. Read SwiftUI Data Flow Patterns next to see how @Observable view models fit into a multi-screen architecture.