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
- The Observation Framework
- Migrating from ObservableObject
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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 |
@ObservedObject | Plain 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 customEnvironmentValues. 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
ObservableObjectand@Observablecoexist, you can migrateFilmStoretoday and tackleCharacterStorenext 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)
| Scenario | Recommendation |
|---|---|
| New project targeting iOS 17+ | Use @Observable exclusively. No reason to reach for ObservableObject. |
| Existing app, iOS 16 minimum | Keep ObservableObject for shared models. Use @Observable for new screens. |
| Existing app, iOS 15 minimum | Stay on ObservableObject. Migration cost outweighs the benefit at iOS 15. |
| Model shared across SwiftUI and UIKit | Use @Observable with withObservationTracking in UIKit contexts. |
| SwiftData models | Use @Model. It includes @Observable behavior. Do not apply @Observable yourself. |
| Class with 1–2 properties, 1–2 views | Performance 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
ObservableObjectfires a singleobjectWillChangenotification for any property change, invalidating all observing views regardless of which property they read.@Observabletracks observation at the property level: only views that accessed a specific keypath re-evaluate when that keypath mutates.- Migration is mechanical — remove
@Published, replace@StateObjectwith@State, drop@ObservedObjectwrappers, and use@Bindablefor two-way bindings. - Use
@ObservationIgnoredto exclude properties (caches, loggers) from tracking. withObservationTrackingenables@Observableconsumption 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.