Observation landed in iOS 17 with almost no fanfare, and it quietly changed how SwiftUI state management works. Three years in, I still see teams treating @Observable as “just another way to write ObservableObject” — and missing the entire point. The framework isn’t a syntax tweak. It changes what gets invalidated when your state changes, which is the single most important performance property of a SwiftUI app.

This guide covers what Observation actually does, how it differs from Combine-based state, how to use it inside and outside SwiftUI, how to migrate a real codebase, and — because every framework has sharp edges — where it bites.

1. What the Observation Framework Actually Is

The Observation framework (import Observation) is Apple’s dependency-tracking system for reference types. Its public star is the @Observable macro (Swift 5.9), which rewrites a class’s stored properties so that every getter records who read this property and every setter notifies those readers.

import Observation

struct Product: Identifiable {
    let id = UUID()
    let name: String
    let price: Decimal
}

@MainActor
@Observable
final class Cart {
    var items: [Product] = []
    var isCheckingOut = false
    var nameOnCard = ""
    var expressCheckout = false

    var totalItems: Int { items.count }
    var subtotal: Decimal { items.reduce(0) { $0 + $1.price } }

    func add(_ product: Product) {
        items.append(product)
    }
}

Three things to notice. First, the class is otherwise unremarkable — no protocol conformance, no @Published, no import Combine. Second, the macro instruments stored properties (items, isCheckingOut); computed properties like totalItems are just read through the tracked storage underneath them. Third, the mechanism is entirely compile-time: the macro expands to calls into an ObservationRegistrar that maintains the property-level read/write dependency graph. No KVO, no runtime swizzling, no Combine publishers.

The mental model that matters: tracking happens at the property level, and it’s triggered by reads. When a SwiftUI view evaluates its body and reads cart.totalItems, that view is registered as a reader of that property chain. When the items array mutates, only that view is invalidated.

2. ObservableObject vs @Observable: What Actually Changes

With ObservableObject, invalidation is object-level. Any @Published change fires objectWillChange, and every view observing the object re-evaluates its body — even views that only read properties that didn’t change. That’s the @StateObject/@ObservedObject model, and it’s been the default since iOS 13.

With @Observable, invalidation is property-level. A view only re-renders if a property it actually read during body evaluation gets mutated. The before/after is mechanical to write but substantial in behavior:

// Before: ObservableObject + Combine
final class SettingsViewModel: ObservableObject {
    @Published var username = ""
    @Published var isSaving = false
    @Published private(set) var errorMessage: String?

    func save() async { /* ... */ }
}

// After: @Observable, iOS 17+
@Observable
final class SettingsViewModel {
    var username = ""
    var isSaving = false
    private(set) var errorMessage: String?

    func save() async { /* ... */ }
}

The second version drops three things: the ObservableObject conformance, the @Published wrappers, and — with them — the entire Combine publisher chain for UI state. objectWillChange is gone. If your ViewModel was publishing @Published values into sink pipelines for UI purposes, those pipelines simply disappear. There is no $viewModel.username.publisher() anymore; there’s nothing to subscribe to, because SwiftUI does the observing natively.

That deletion is the point. Combine is a fantastic tool for event streams — debouncing, merging, retrying, transforming async sequences. It was never a great fit for current state, which is what SwiftUI actually renders. Observation removes it from that job.

3. @Observable in SwiftUI: @State, @Bindable, @Environment

Owning with @State

When a view owns an @Observable object, store it in @State. Since iOS 17, @State accepts reference types conforming to the Observable protocol:

struct CartView: View {
    @State private var cart = Cart()

    var body: some View {
        List(cart.items) { item in
            ProductRow(item: item)
        }
        .safeAreaInset(edge: .bottom) {
            CheckoutBar(count: cart.totalItems, subtotal: cart.subtotal)
        }
    }
}

@State here does the lifecycle work that @StateObject used to: it keeps the object alive for the view’s lifetime and ties invalidation to property reads. CheckoutBar reads cart.totalItems, so it’s invalidated when items change — but a view that only reads cart.isCheckingOut is not.

Binding with @Bindable

$cart.nameOnCard works directly when the object lives in @State. But when you pass the object down to a child view that needs bindings, the child needs @Bindable:

struct CheckoutForm: View {
    @Bindable var cart: Cart

    var body: some View {
        Form {
            TextField("Name on card", text: $cart.nameOnCard)
            Toggle("Express checkout", isOn: $cart.expressCheckout)
        }
    }
}

@Bindable wraps an observable object and exposes $ bindings to its tracked properties. It’s the replacement for passing @ObservedObject into child views that need @Binding.

Environment and @EnvironmentObject

@EnvironmentObject maps to @Environment(T.self) — observation-keyed environment, injected by type rather than key path:

@main
struct ShopApp: App {
    @State private var cart = Cart()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(cart)
        }
    }
}

struct RootView: View {
    @Environment(Cart.self) private var cart

    var body: some View {
        TabView {
            CatalogView()
            CartTab()
        }
    }
}

Reading @Environment(Cart.self) in RootView.body registers that view against whatever properties it reads. And yes, you can bind to an environment object — declare a local @Bindable inside body:

struct LoginSheet: View {
    @Environment(AppModel.self) private var appModel

    var body: some View {
        @Bindable var appModel = appModel
        TextField("Email", text: $appModel.email)
    }
}

The same crash-on-missing-value semantics as @EnvironmentObject apply: if nothing injects Cart.self up the tree, reading it is a fatal error.

4. Observation Outside SwiftUI: withObservationTracking

SwiftUI is the most visible consumer of Observation, but the framework itself is UI-agnostic. withObservationTracking(_:onChange:) exposes raw property-level tracking to any Swift code — UIKit, AppKit, or a plain service object.

import Observation
import UIKit

final class CartTabViewController: UIViewController {
    private let cart: Cart
    private var token: ObservationToken?

    init(cart: Cart) {
        self.cart = cart
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        rearm()
    }

    private func rearm() {
        token = withObservationTracking {
            // Synchronously read the properties you care about.
            cart.totalItems
        } onChange: {
            // Fires asynchronously after the mutation; observation is one-shot.
            Task { @MainActor [weak self] in
                self?.tabBarItem.badgeValue = "\(self?.cart.totalItems ?? 0)"
                self?.rearm()
            }
        }
    }
}

Three behaviors matter here. First, the apply closure must read the properties synchronously — that read is what registers the dependency. Second, observation is one-shot: after a tracked property mutates and onChange fires, you must re-arm by calling withObservationTracking again (that’s the rearm() recursion). Third, withObservationTracking returns an ObservationToken; releasing it (or calling its cancel() method) stops the observation.

This is how you get SwiftUI-style reactive state in a UIKit app without KVO or a Combine @Published pipeline. It’s also how you test observation behavior, which we’ll get to in a moment. One consistency note: Cart is @MainActor (Section 1), and UIViewController subclasses are main-actor isolated, so every read above already happens on the main actor — the Task { @MainActor in ... } hop is mostly a formality that keeps the code correct if the model’s isolation ever changes.

When that shared model also drives a SwiftUI screen or reusable cell, the guide to embedding SwiftUI in UIKit with UIHostingController and UIHostingConfiguration shows where to keep ownership and observation at the framework boundary.

5. Migrating an ObservableObject MVVM Codebase

If you’ve built your app on the ObservableObject MVVM pattern — and I’ve written at length about that architecture in MVVM with Clean Architecture in iOS: A Practical Guide — the good news is that migration is mechanical and incremental. The architecture doesn’t change, only the glue.

The MVVM shape survives intact: ViewModels still own screen state, talk to use cases, and expose mutations. What changes is how the View observes the ViewModel. And because SwiftUI treats both paradigms as first-class, both can coexist in one app — and even in one screen hierarchy — while you migrate.

Per view model, the changes are nearly copy-paste:

  1. Delete : ObservableObject and all @Published wrappers.
  2. Add @Observable above the class declaration.
  3. Delete Combine sink pipelines that existed only to shuffle @Published values into views.
  4. In views: @StateObject becomes @State, @ObservedObject becomes a plain let (or @Bindable if the view needs bindings), @EnvironmentObject becomes @Environment(T.self).
struct SettingsScreen: View {
    @State private var viewModel = SettingsViewModel()   // was @StateObject

    var body: some View {
        SettingsForm(viewModel: viewModel)
    }
}

struct SettingsForm: View {
    @Bindable var viewModel: SettingsViewModel            // was @ObservedObject

    var body: some View {
        TextField("Username", text: $viewModel.username)   // was $viewModel.username too
    }
}

Do it one screen at a time, driven by whatever feature you’re touching. There’s no benefit to a big-bang rewrite.

The one non-negotiable constraint is the deployment floor: @Observable requires iOS 17, full stop. There is no backport. If your app supports iOS 16, every file that uses @Observable needs an iOS 17+ deployment target or @available gating — which means a shared @Observable model used by both an iOS 16 and iOS 17 screen path becomes awkward. A practical strategy: set your floor to iOS 17 on your next major release, and migrate screens as you raise the minimum. Meanwhile, both paradigms coexist without friction.

6. Testing @Observable Models

One of Observation’s quiet wins: it’s pure Swift with no runtime dependencies, so observation behavior is unit-testable. withObservationTracking lets you assert that mutating a property actually fires observers — useful when your ViewModel’s contract is “this property invalidates when that state changes.”

import Observation
import Testing

@testable import ShopApp

@MainActor
struct CartObservationTests {
    @Test func appendingItemInvalidatesTotalItems() async {
        let cart = Cart()

        await confirmation("observer fires", expectedCount: 1) { confirm in
            withObservationTracking {
                _ = cart.totalItems
            } onChange: {
                confirm()
            }

            cart.add(Product(name: "Coffee", price: 4.5))
        }
    }
}

The onChange callback fires asynchronously, so the test needs to wait — Swift Testing’s confirmation is the idiomatic way to express “this must happen exactly once.” If you’re still on XCTest, an XCTestExpectation plays the same role. For a full look at the modern framework, I covered confirmation, traits, and parameterized tests in Swift Testing: A Practical Guide for Senior Engineers.

That said, be honest about what you’re testing. Most of the time you want to test the logic — that cart.add() computes the right subtotal — not the observation wiring. Observation tests earn their keep when the wiring is the contract: a view model that conditionally publishes state, a UIKit badge that must update, or a migration where you want proof that property-level invalidation is actually happening.

7. Pitfalls and Sharp Edges

It’s still a class: reference semantics, identity, no copies

@Observable only works on classes, which means all of the reference-type baggage comes along: shared mutation, identity, and no value copies. Two views holding the same Cart see the same mutations — usually what you want — but the classic bugs follow. Passing an @Observable model into a background context, caching it in a dictionary, or comparing it with == all behave exactly as they do for any class. My analysis of value versus reference semantics — and why structs remain the default for pure data — applies here unchanged in Struct vs Class in Swift: Making the Right Choice.

Collection invalidation granularity

Property-level tracking has a subtle interaction with collections. Consider cart.items:

  • cart.items.append(product) mutates the array property → observers of items are invalidated.
  • cart.items[0].isFavorite = true where items is [Product] (value elements) goes through the array’s subscript, which is still a mutation of the items property → observers of items are invalidated.
  • But if items is [CartLine] where CartLine is itself @Observable, mutating cart.items[0].quantity tracks that instance’s property. A view that only read cart.items.count is not invalidated; a view that read line.quantity is. That per-element granularity is exactly what makes ForEach over observable elements efficient — and it means “the collection changed” and “an element changed” are different signals you must design around.

@MainActor expectations

SwiftUI views are implicitly @MainActor, and the @Observable models they render almost always should be too. If a model is mutated from a background context, SwiftUI’s diffing can observe an inconsistent intermediate state — and under Swift 6 strict concurrency the compiler will refuse to let you pass a non-Sendable model across the boundary in the first place. The pragmatic default for any model that touches the UI:

@MainActor
@Observable
final class Cart { /* ... */ }

When loading data into an observable model makes scrolling stutter, keeping Swift async image processing off the main actor walks through the worker boundary and a cancellable SwiftUI .task.

Sendable under Swift 6

This is the real constraint. A bare @Observable class is a mutable reference type, and it is not Sendable — in Swift 6 language mode, a non-isolated instance cannot cross an isolation boundary. The @MainActor annotation changes that: global-actor-isolated types are implicitly Sendable, so a @MainActor @Observable model can be passed wherever main-actor code is expected. What it can’t do is hop to a Task.detached, an actor method, or a background queue — the main actor’s state is off-limits to them. The workable patterns are: annotate the model @MainActor (the default for SwiftUI-bound models) and mutate it via Task { @MainActor in ... }, or keep your cross-boundary data as Sendable structs and have the @MainActor @Observable model reflect snapshots of them. The full trade-off between structs and classes under strict concurrency — and when @unchecked Sendable is and isn’t justified — is the subject of Struct vs Class in Swift 6: Concurrency-Safe Choices.

deinit and observation cleanup

Observation cleanup is mostly automatic: when an observed object deallocates, its registrar goes with it, and an ObservationToken cancels its observation when released. The leak risk is the usual one — a strong retain cycle. If your onChange closure captures self and you hold the token on self, neither deallocates. Re-arm loops like the UIKit example above must use [weak self], and view models that observe themselves need the same discipline.

8. When Not to Use @Observable

For all the enthusiasm, @Observable is not the default answer for everything:

  • Trivial one-way static data. A view that receives a finished struct Order and renders it needs no observation at all.
  • Value-type models. If your model is an immutable struct and you never share identity, Observation buys you nothing — you’d be wrapping a value type in a class just to use it.
  • iOS 16 or earlier deployment targets. The floor is real.
  • Teams not ready for the iOS 17 floor. If parts of your app still need Combine-based state for UIKit screens, keeping ObservableObject there is not a compromise, it’s correct engineering.
  • Plain local UI state. A single @State var isPresented = false in one view does not need to become @Observable. The framework shines for shared, mutated, multi-view state — not for every Boolean in a form.

Key Takeaways

  • @Observable is property-level dependency tracking on classes; SwiftUI re-renders only views that read a changed property, not every view that touches the object.
  • It removes @Published, objectWillChange, and Combine from the UI-state job — Combine stays for event streams.
  • In SwiftUI: own with @State, bind with @Bindable, inject with .environment(_:) and read with @Environment(T.self).
  • withObservationTracking(_:onChange:) brings the same model to UIKit, AppKit, plain logic, and tests — remember it’s one-shot and must be re-armed.
  • Migration from ObservableObject MVVM is mechanical and incremental; both paradigms coexist, but the iOS 17 deployment floor is non-negotiable.
  • Watch the sharp edges: reference semantics, collection invalidation granularity, @MainActor, and Swift 6 Sendable constraints.
  • Reach for it when state is shared and mutated across views — not for static data, value-type models, or single-screen @State values.