Home

Observation

How SwiftUI knows which properties a view actually read

@Observable looks like a shorter spelling of ObservableObject. It is a different mechanism, and the difference is the whole point.

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

No @Published, no ObservableObject, no objectWillChange. The macro rewrites every stored property into a computed one that reports reads and writes to an ObservationRegistrar.

The old model announced; the new model is asked

With ObservableObject, the object fires objectWillChange on any @Published write, and every view observing it redraws — whether or not it touched the property that changed. A view showing only books redrew every time isSyncing flipped or a character was typed into searchText.

With @Observable, SwiftUI evaluates body inside a tracking scope. Every property read during that evaluation is recorded, and the view is invalidated only when one of those properties changes.

struct BookList: View {
    let library: Library

    var body: some View {
        List(library.books) { BookRow(book: $0) }   // reads `books` only
    }
}

Typing in the search field no longer redraws this view. Nothing was configured to achieve that — it falls out of which properties body happened to read.

Tip

The tracking is per evaluation, not per view type. A view that reads a property only inside an if branch is not tracking it while that branch is false. Add the branch, and the dependency appears on the next update.

What to write, and what to stop writing

The wrapper on the property is decided by what the view does with the object, not by the object:

struct LibraryScreen: View {
    @State private var library = Library()      // this view owns it

    var body: some View {
        BookList(library: library)              // just pass it; no wrapper to read
        SearchField(library: library)
    }
}

struct SearchField: View {
    @Bindable var library: Library              // needs a binding into it

    var body: some View {
        TextField("Search", text: $library.searchText)
    }
}
  • Nothing — reading is enough, and is the common case.
  • @State — this view creates the object and it should live as long as the view does.
  • @Bindable — you need $model.property for a TextField, Toggle or similar.
  • @Environment(Library.self) — it was injected from above.

@ObservedObject, @StateObject, @EnvironmentObject and @Published are the previous generation. You will meet them in existing code and there is no need to rush a migration, but nothing new should use them.

Where the tracking silently fails

Observation only sees what it can intercept, and there are three real gaps.

Computed properties over non-observed storage. A computed property reading a plain private var that the macro did not rewrite is invisible; nothing reports the read.

Reading outside body. Tracking is established while body is being evaluated. A property read inside a Task closure, a completion handler or onAppear is not a dependency, and mutating it later will not redraw anything.

Collections of observable objects. library.books tracks the array — insertions, removals, reorderings. It does not track a property inside an individual Book. If Book is a class, mark it @Observable too and let each row read it, which is what you want anyway: only that row redraws.

struct BookRow: View {
    let book: Book              // @Observable class

    var body: some View {
        Text(book.title)        // this row alone redraws when the title changes
    }
}

The one that bites hardest

Passing a whole model down and reading it at the top:

struct Dashboard: View {
    let library: Library

    var body: some View {
        let count = library.books.count      // a read, right here in Dashboard
        Header(count: count)
        BookList(library: library)
    }
}

Dashboard now depends on books, so it re-evaluates on every change to the array — and its whole subtree is rebuilt with it. Push the read down into the view that needs it, and Dashboard stops caring.

That is the same “state at the lowest view that needs it” rule from the section introduction, in the form it usually appears in real code: not where the state is stored, but where it is read.