What is the difference between ObservableObject and @Observable in SwiftUI?

Compare Combine-era observation with the iOS 17 Observation system, focusing on invalidation granularity, boilerplate, and how ownership changes in SwiftUI

Answer

The core idea

ObservableObject is SwiftUI's original Combine-based observation model. A reference type publishes change events, usually through @Published, and SwiftUI re-renders views that depend on that object. Starting in iOS 17, Apple's Observation system introduces @Observable, which tracks property access more precisely and removes a lot of the manual publishing boilerplate.

The short interview answer is: ObservableObject broadcasts that an object changed, while @Observable lets SwiftUI track which properties a view actually read and invalidate more precisely when those properties change.

1. The old model: ObservableObject plus @Published

With the Combine-era approach, the model conforms to ObservableObject, and each property that should trigger UI updates must be marked @Published.

swift
import SwiftUI
import Combine

final class ProfileModel: ObservableObject {
    @Published var displayName = "Taylor"
    @Published var unreadCount = 3
    @Published var isPro = false
}

This works, but it has two common costs:

  • the model only updates the UI for properties you remembered to mark @Published
  • the signal is broadly "something in this object changed", which is less precise as the model grows

That second point matters in larger screens. If several subviews depend on the same big view model, a single @Published change can cause more recomputation than the screen logically needs.

2. The new model: @Observable

With Observation, the macro generates the tracking machinery for you. Plain stored properties participate automatically, so the model becomes simpler to write and easier to evolve.

swift
import Observation

@Observable
final class ProfileModel {
    var displayName = "Taylor"
    var unreadCount = 3
    var isPro = false
}

The important behavior change is not just less boilerplate. SwiftUI records which properties a view reads during body. If a view only reads unreadCount, then changing displayName does not need to invalidate that view.

That is why Observation scales better for wide models. It is closer to dependency tracking than object-wide broadcasting.

3. What @Observable does under the hood

Under the hood, @Observable is a macro, not magic. Per SE-0395: Observability, it synthesizes an ObservationRegistrar, rewrites stored properties into tracked computed properties, and uses helper calls to record reads and wrap mutations.

Conceptually, this:

swift
import Observation

@Observable
final class ProfileModel {
    var unreadCount = 3
}

roughly becomes something like this:

swift
import Observation

final class ProfileModel: Observable {
    @ObservationIgnored
    private let _$observationRegistrar = ObservationRegistrar()

    @ObservationIgnored
    private var _unreadCount = 3

    var unreadCount: Int {
        get {
            access(keyPath: \.unreadCount)
            return _unreadCount
        }
        set {
            withMutation(keyPath: \.unreadCount) {
                _unreadCount = newValue
            }
        }
    }
}

The important pieces are:

  • reads call access(keyPath:), which registers that this property was used
  • writes go through withMutation(keyPath:), which tells the registrar that this property is changing
  • SwiftUI can then connect "this view read unreadCount" to "this write changed unreadCount"

At a slightly deeper level, SwiftUI evaluates body inside withObservationTracking { ... }. That scope records which tracked properties were read while building the view. Later, when one of those properties mutates, the registrar triggers change handling for the views that actually depended on that key path. That is the mechanism behind the more targeted invalidation story.

4. The view-layer ownership story changes

ObservableObject usually appears with @StateObject, @ObservedObject, and @EnvironmentObject. On iOS 17+, @Observable models often use different wrappers:

  • own the model with @State
  • pass it down as a plain stored property or let
  • use @Bindable in a child that needs bindings such as $model.displayName
  • inject shared models with @Environment
swift
import SwiftUI
import Observation

@Observable
final class ProfileModel {
    var displayName = "Taylor"
    var isPro = false
}

struct ProfileScreen: View {
    @State private var model = ProfileModel()

    var body: some View {
        ProfileEditor(model: model)
    }
}

struct ProfileEditor: View {
    @Bindable var model: ProfileModel

    var body: some View {
        TextField("Name", text: $model.displayName)
        Toggle("Pro", isOn: $model.isPro)
    }
}

The ownership question still matters. SwiftUI still needs a stable place to keep the model alive. What changes is the observation mechanism, not the need to reason clearly about who owns the data and who only reads or edits it.

5. Why it matters

This difference shows up in real apps, not just in API style.

  • large view models create less redraw churn when unrelated fields no longer invalidate the same subtrees
  • teams write less boilerplate because they do not need @Published on every reactive property
  • fewer refresh bugs slip through when a developer adds a new stored property and forgets to publish it
  • feature models become easier to split or keep together based on domain boundaries rather than redraw workarounds

That last point is especially useful in interviews: many teams used to split one large ObservableObject into several smaller ones partly to reduce broad invalidation. Observation directly targets that pain.

Interview angle

Walk the answer in this order: ObservableObject is the old Combine model -> @Published manually opts properties into updates -> @Observable uses tracked property reads plus mutation tracking -> that gives SwiftUI a more precise dependency model -> the view-side wrappers also change (@State, @Bindable, @Environment) even though ownership still matters.