What does the @Observable macro do, and how is it different from ObservableObject?
TL;DR
@Observable comes from the Observation framework (iOS 17, Swift 5.9).
It tracks which properties a view reads in body. When one of those changes, only that view updates.
ObservableObject sends one objectWillChange signal for any @Published change, so every observing view updates.
It also means less code: no @Published, no @StateObject.
Before and after
// Old: Combine based
final class CartStore: ObservableObject {
@Published var items: [Item] = []
@Published var coupon: String?
}
// New: Observation
@Observable
final class CartStore {
var items: [Item] = []
var coupon: String?
}Every stored property is tracked by default.
Fine-grained updates
struct CartCount: View {
let store: CartStore
var body: some View {
Text("\(store.items.count) items") // reads only `items`
}
}If coupon changes, CartCount does not update. With ObservableObject, it would.
This is the main performance win.
What the macro generates (simplified)
final class CartStore: Observable {
@ObservationTracked var items: [Item] = []
private let _$observationRegistrar = ObservationRegistrar()
// getters call registrar.access(...), setters call registrar.withMutation(...)
}When body runs, SwiftUI records each access. A later withMutation on one of those properties invalidates the view.
Using it in views
| Need | Write |
|---|---|
| View creates and owns it | @State private var store = CartStore() |
| View only reads it | let store: CartStore |
View needs $store.coupon | @Bindable var store: CartStore |
| Shared down the tree | .environment(store) + @Environment(CartStore.self) |
Ignoring a property
@Observable final class Player {
var title = ""
@ObservationIgnored private var timer: Timer?
}Use @ObservationIgnored for caches, timers, and other internal state that should not update views.
Tracking outside SwiftUI
withObservationTracking fires once on the next change:
withObservationTracking {
print(store.items.count)
} onChange: {
print("items will change")
}From Swift 6.2 (iOS 26) you can use Observations, an AsyncSequence of values:
for await count in Observations({ store.items.count }) {
print("Count is now", count)
}Concurrency notes
An @Observable class is not Sendable by default. For UI models, mark the class @MainActor:
@MainActor @Observable
final class CartStore { var items: [Item] = [] }In Swift 6.2 projects with "default actor isolation" set to MainActor, this is the default.
Common gotchas
- Only works with classes, not structs.
- Computed properties are tracked through the stored properties they read.
- Properties read in a closure that runs later (like a
Buttonaction) are not tracked. Only reads duringbodycount. @Publishedand Combine publishers do not exist on@Observabletypes. If you need a publisher, keepObservableObjector useObservations.
Common follow-up questions
- Can I still use
ObservableObject? Yes. It is not deprecated, and you need it for iOS 16 and earlier. - Why
@Statefor a class now?@Statekeeps one instance alive for the view's lifetime. It replaces@StateObject. - Is the initializer of a
@Stateobject called many times? The expression can run on each view init, but SwiftUI keeps the first instance. Keepinitcheap. - Does it track arrays deeply? It tracks the
itemsproperty. Mutating the array counts as a change toitems.