•5 min read

Migrating from ObservableObject to @Observable in SwiftUI

I put this migration off for a long time. The app worked fine on ObservableObject, I'd been writing @StateObject and @Published since before iOS 17 even existed, and nothing was on fire. Then I opened Instruments while chasing a janky profile screen and watched a tiny unread badge redraw itself on every single keystroke in a search field three views away. That was the push I needed.

Here's what was actually going on. ObservableObject does object-level tracking. Change any @Published property and every view observing that object re-evaluates its body, whether it reads the changed property or not. @Observable, which shipped with the Observation framework in iOS 17, tracks at the property level instead. A view only redraws when a property it actually read inside body changes. On a toy app you'll never notice. On a real screen with a dozen subviews, it shows up in your frame budget.

An iPhone lying on a desk showing its home screen

The migration itself is mostly deletion

Take a plain view model. Before:

import SwiftUI
import Combine

class ProfileModel: ObservableObject {
    @Published var username = ""
    @Published var unreadCount = 0
}

After:

import SwiftUI

@Observable
class ProfileModel {
    var username = ""
    var unreadCount = 0
}

Drop the protocol, drop @Published, add the macro. Every stored property is tracked by default now. If there's something a view reads but shouldn't trigger updates, like a logger or a cached value, mark it with @ObservationIgnored. That's the whole story for the model side.

The view wrappers follow the same pattern. @StateObject becomes @State, yes, @State holding a class now. @EnvironmentObject becomes @Environment with the type spelled out. @ObservedObject in a child view just becomes a plain property. My favorite detail is that @Environment(ProfileModel.self) fails at runtime with a clear message if you forgot to inject the object, instead of the old "no ObservableObject of type found" hunt through the view tree.

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

    var body: some View {
        ProfileBadge()
            .environment(model)
    }
}

struct ProfileBadge: View {
    @Environment(ProfileModel.self) private var model

    var body: some View {
        Text("Unread: \(model.unreadCount)")
    }
}

And the badge only redraws when unreadCount changes now. Typing in the username field doesn't touch it. That one behavior change fixed the jank I was chasing in the first place.

Two things that bit me

First, bindings in child views. The moment a child needs $model.username for a TextField, a plain property won't compile. You need @Bindable:

struct ProfileForm: View {
    @Bindable var model: ProfileModel

    var body: some View {
        TextField("Username", text: $model.username)
    }
}

The compiler error you get without it points at the projected value, and I wasted ten minutes staring at @State in the parent thinking that was the problem. It wasn't. @Bindable on the child property is the fix, and I keep that snippet saved in Snippet Ark now because I forget it every time.

Second, objectWillChange is gone. Anywhere outside SwiftUI that subscribed to it breaks. My app had exactly one of those: a legacy UIKit view controller listening for model changes. Under the new system there's no direct replacement, just withObservationTracking and, more recently, the Observations async sequence in Swift 6.2. I didn't rewrite anything. I left that one screen on ObservableObject and isolated it at the boundary. Mixing both systems in one app is fine.

You can do this one screen at a time

This is the part Apple's docs don't shout about enough. @StateObject, @EnvironmentObject, and .environmentObject() all still work with types that use @Observable. So you can convert a single model, run the app, and nothing else breaks. That's how I did it: one model per commit, twelve view models over an afternoon, and the only non-mechanical work was the two spots above.

Where would I still hold off? Deployment target, first. @Observable needs iOS 17 or later, so any screen that has to support iOS 16 stays on the old system unless you're willing to ship both paths. There are backport libraries like Point-Free's Perception, but adding a dependency to postpone a migration never felt right to me.

Heavy Combine is the other one. If a view model is built around debounce, combineLatest, and a chain of operators wired to @Published, converting it means rethinking the data flow, not just swapping wrappers. My two gnarliest models stayed put and I don't feel bad about it.

One last improvement that doesn't get enough credit. Apple's docs point out that Observation can track optionals and collections of objects, which ObservableObject never could. In practice: an array of @Observable items, where a view reads one item's properties directly, now updates when that item changes. The old system just dropped those updates silently. If you've ever patched around that with a manual refresh flag, this migration deletes the flag along with everything else.

Net result after the full pass: fewer body evaluations, no @Published bookkeeping, and one less concept for whoever reads this code next. If your deployment target already allows iOS 17, stop writing @StateObject out of habit like I did. Start with the screen that redraws too much. And if you're touching old view models, you'll probably trip over strict concurrency right after, so my post on Swift 6 data races pairs well with this one. The official migration guide covers every step I skipped.