Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

@Observable Versus ObservableObject

~15 min · appkit-swiftui, observation, swiftui, state

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The mic button does nothing until I type something into the composer. And it isn't even every time." — Dad, on a TestFlight build

Two Generations of "The View Should Update"

ObservableObject (Combine) is the older model. A class publishes changes through @Published properties and objectWillChange; a view subscribes to the object it holds with @ObservedObject or @StateObject, and any published change invalidates that whole view.

@Observable (the Observation framework, macOS 14 / iOS 17 / watchOS 10) is the newer one. The macro makes a class track which properties a view's body actually reads, and only changes to those properties re-render it. You hold the model as a plain property or in @State, and use @Bindable when you need bindings such as $model.draft.

The Bug That Looked Like Broken Hardware

The family's first native dictation shipped with a microphone button that seemed to do nothing. Sometimes it came alive after the user typed a character; sometimes it seemed to switch on by itself. It behaved exactly like flaky hardware. The microphone was recording the whole time.

The app model was an ObservableObject, and it held the dictation object — itself an ObservableObject — as a plain property. SwiftUI subscribes only to the object a view holds. The dictation object's @Published changes reached no view at all. Typing changed a @Published property on the model, the screen redrew, and the listening state appeared along with it. The fixes available under ObservableObject are to relay the child's objectWillChange into the parent's, or to hold the child as its own @ObservedObject in the view. Under Observation the trap does not exist: reading model.dictation.isListening in a body tracks that property through the nested object. The family's shared speech component is @Observable for exactly that reason.

The general lesson from Dad's report: a symptom that looks like broken hardware and correlates with unrelated UI activity is a redraw problem until proven otherwise.

The House Rule, and One Compiler Footnote

Every family deployment target already supports Observation, so the rule is @Observable for new models, with existing ObservableObject models migrated as they are touched. One footnote from the build Mac: Swift 6.3.3 crashed in code generation on two occasions when a SwiftUI Binding setter was written as a bound method reference, set: model.selectProvider. Writing the explicit closure set: { model.selectProvider($0) } compiled. The minimal trigger was never isolated, so treat it as a workaround to reach for when you see that crash, not a ban on method references.

Code

The nested-object trap, and the Observation version that has no trap·swift
import SwiftUI
import Combine

// The trap: a nested ObservableObject held as a plain property.
@MainActor final class DictationOld: ObservableObject { @Published var isListening = false }

@MainActor final class ComposerModelOld: ObservableObject {
    @Published var draft = ""
    let dictation = DictationOld()     // its @Published changes reach NO view through this model
}

struct ComposerViewOld: View {
    @ObservedObject var model: ComposerModelOld
    var body: some View {
        VStack {
            Text(model.dictation.isListening ? "Listening…" : "Tap to talk")   // stale until draft changes
            TextField("Draft", text: $model.draft)                            // typing here refreshes the label
        }
    }
}

// The fix: Observation tracks what the body reads, through nested @Observable objects.
@MainActor @Observable final class Dictation { var isListening = false }

@MainActor @Observable final class ComposerModel {
    var draft = ""
    let dictation = Dictation()
    var provider = "local"
    func selectProvider(_ name: String) { provider = name }
}

struct ComposerView: View {
    @Bindable var model: ComposerModel
    var body: some View {
        VStack {
            Text(model.dictation.isListening ? "Listening…" : "Tap to talk")
            TextField("Draft", text: $model.draft)
            Picker("Provider", selection: Binding(
                get: { model.provider },
                set: { model.selectProvider($0) }     // explicit closure, not set: model.selectProvider
            )) {
                Text("Local").tag("local")
                Text("Remote").tag("remote")
            }
        }
    }
}

External links

Exercise

Build both versions from the code block in a small SwiftUI window in Spark. Add a button that toggles dictation.isListening after a one-second delay. In the ObservableObject version, confirm the label does not update until you type in the text field; in the @Observable version, confirm it updates immediately. Then fix the old version without migrating it, by relaying the child's objectWillChange into the parent.
Hint
In ComposerModelOld's initializer, subscribe to dictation.objectWillChange and forward each event to self.objectWillChange.send(), keeping the AnyCancellable in a stored property.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.