Home

Migrating ObservableObject to @Observable, and the redraws that disappeared

I expected @Observable to be a syntax cleanup. Delete @Published, delete the protocol conformance, move on. It is that, and it is also a different observation mechanism, and the difference showed up as a measurable performance change in a screen I had already given up on.

The mechanical part

// before
final class Library: ObservableObject {
    @Published var books: [Book] = []
    @Published var searchText = ""
    @Published var isSyncing = false
}

// after
@Observable
final class Library {
    var books: [Book] = []
    var searchText = ""
    var isSyncing = false
}

Then the property wrappers at every use site:

Before After
@StateObject var model = Model() @State var model = Model()
@ObservedObject var model: Model let model: Model
@EnvironmentObject var model: Model @Environment(Model.self) var model
.environmentObject(model) .environment(model)
@ObservedObject + $model.name @Bindable var model: Model

The row worth pausing on is the second. An @Observable object that a view only reads needs no property wrapper at all — a plain let is enough. That felt wrong for a week and it is correct: tracking is established by reading the property inside body, not by the wrapper.

The part that is not mechanical

@Observable tracks reads. ObservableObject announced writes. That difference means the location of your reads is now a performance decision, and nothing in the migration flags it.

Here is the shape that bit me:

struct LibraryScreen: View {
    let library: Library

    var body: some View {
        VStack {
            Text("\(library.books.count) books")     // a read, in the parent
            SearchField(library: library)
            BookList(library: library)
        }
    }
}

LibraryScreen reads books, so it re-evaluates whenever the array changes — and its whole subtree goes with it. Under ObservableObject this was already happening, so nothing got worse. But the whole benefit of @Observable was available one refactor away:

struct BookCount: View {
    let library: Library
    var body: some View { Text("\(library.books.count) books") }
}

Now only BookCount re-evaluates. LibraryScreen reads nothing and never re-runs.

Tip

The migration checklist item nobody writes down: after converting, look at every view that takes a model and reads a property directly in a parent-level body. Each one is a subtree redrawing for a change it does not care about, and extracting a small view fixes it.

What it actually bought

The screen I cared about was a document list with a search field and a sync indicator. Under ObservableObject, typing one character in the search field re-evaluated every row — because searchText was @Published, and objectWillChange does not distinguish.

With @Observable and the reads pushed down, typing re-evaluates the search field and nothing else. Using Self._printChanges() to count: 47 view body evaluations per keystroke before, 2 after.

That was not a micro-optimisation. It was the difference between a search field that felt laggy on an older phone and one that did not, and I had previously “fixed” it by adding a debounce — which made the lag less frequent rather than absent.

Three things that caught me out

@Environment(Model.self) crashes if nothing injected it. @EnvironmentObject did too, but the new one crashes in the view that reads it with a slightly different message. Every preview of every view below the injection point needs the object, and there is no compile-time check.

Computed properties over non-observed storage are invisible. If a computed property reads a private var the macro did not rewrite, nothing reports the read and the view never updates.

Arrays of classes need the element observed too. library.books tracks the array — insertions, removals, reordering. It does not track a property inside a Book. If Book is a class, mark it @Observable and let each row read it; that is also what you want, since only that row then redraws.

Migration order that worked

Not all at once. A file at a time, leaf-first:

  1. Model types with no dependents first. Convert, fix the call sites the compiler points at, run.
  2. Then the views that use them, extracting subviews wherever a parent was reading a property.
  3. Environment injection last, because that is the change with no compile-time safety net and it is easier to verify when everything below it already works.

The two systems coexist fine, so a half-migrated app builds and runs. That mattered more than I expected — it meant the work could go in over a week of small commits rather than one enormous unreviewable change.

Should you bother

If the app is on a recent OS: yes, and sooner than you think. ObservableObject is not deprecated but it is clearly the previous generation, and the migration is genuinely mechanical apart from the one judgement call above.

If you are supporting older systems, @Observable needs iOS 17, and the two cannot be mixed in one type. That is a real blocker and there is no polyfill worth using — the answer is to wait, and to avoid writing new ObservableObject types in the meantime where you can.