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.propertyfor aTextField,Toggleor 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.