iosNudge

What does the @Observable macro do, and how is it different from ObservableObject?

MediumAsked oftenSwiftUICombine 2 min read

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

NeedWrite
View creates and owns it@State private var store = CartStore()
View only reads itlet 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 Button action) are not tracked. Only reads during body count.
  • @Published and Combine publishers do not exist on @Observable types. If you need a publisher, keep ObservableObject or use Observations.

Common follow-up questions

  • Can I still use ObservableObject? Yes. It is not deprecated, and you need it for iOS 16 and earlier.
  • Why @State for a class now? @State keeps one instance alive for the view's lifetime. It replaces @StateObject.
  • Is the initializer of a @State object called many times? The expression can run on each view init, but SwiftUI keeps the first instance. Keep init cheap.
  • Does it track arrays deeply? It tracks the items property. Mutating the array counts as a change to items.