@Observable and shared model state
The Observation framework replaced ObservableObject — and it changed which views re-render, not just the syntax.
By the end you will be able to
- Write an @Observable model and connect it to views
- Explain why @Observable re-renders fewer views than ObservableObject did
- Choose between @State, @Bindable and plain access for a model
A model has ten properties. A view reads only one of them. With @Observable, when does that view re-render?
The modern shape
@Observable
final class LibraryModel {
var books: [Book] = []
var searchText = ""
var isLoading = false
var filteredBooks: [Book] {
searchText.isEmpty
? books
: books.filter { $0.title.localizedStandardContains(searchText) }
}
func load() async {
isLoading = true
defer { isLoading = false }
books = await api.fetchBooks()
}
}
No @Published, no ObservableObject, no objectWillChange. A plain class with a macro on it. The macro rewrites the stored properties to report reads and writes to the Observation runtime.
Views use it like any other value:
struct LibraryView: View {
@State private var model = LibraryModel()
var body: some View {
List(model.filteredBooks) { book in
Text(book.title)
}
.task { await model.load() }
}
}
@State here means this view owns the model's lifetime — it is created once and kept alive across re-renders. That is the same job @StateObject used to do.
final class Model: ObservableObject {
@Published var items: [Item] = []
@Published var query = ""
@Published var isLoading = false
}
struct ContentView: View {
@StateObject private var model = Model()
var body: some View {
Text(model.query)
// re-renders when items or
// isLoading change too
}
}@Observable
final class Model {
var items: [Item] = []
var query = ""
var isLoading = false
}
struct ContentView: View {
@State private var model = Model()
var body: some View {
Text(model.query)
// re-renders only when
// query changes
}
}Less syntax, but the real win is the second comment. In a screen with a long list and a search field, the old version re-rendered the whole list on every keystroke.
Passing a model down
A child that only reads takes it as a plain let:
struct BookRow: View {
let book: Book // just data
var body: some View { Text(book.title) }
}
A child that needs bindings into the model takes @Bindable:
struct SearchField: View {
@Bindable var model: LibraryModel
var body: some View {
TextField("Search", text: $model.searchText)
}
}
@Bindable exists to produce $model.property bindings. Without it you can still read and mutate the model directly — observation works through any reference — you simply cannot make a Binding out of it.
Or put it in the environment
For a model that many distant views need:
@main
struct LibraryApp: App {
@State private var model = LibraryModel()
var body: some Scene {
WindowGroup {
LibraryView()
.environment(model)
}
}
}
struct DeepChildView: View {
@Environment(LibraryModel.self) private var model
var body: some View { Text("\(model.books.count) books") }
}
This avoids threading the model through five initializers. It is also easy to overuse: environment dependencies are invisible at the call site, so a view that reads three environment models is hard to test and hard to reason about. Pass explicitly when you reasonably can.
What the macro actually does
@Observable rewrites each stored property into a computed one:
var searchText: String {
get {
access(keyPath: \.searchText) // "this view read this property"
return _searchText
}
set {
withMutation(keyPath: \.searchText) { _searchText = newValue }
}
}
When SwiftUI evaluates a body, it records every property accessed. When one of those is mutated, exactly the views that recorded it are invalidated. That is the whole mechanism, and knowing it explains the failure modes.
Deriving state instead of storing it
The best thing about a model class is that derived values become computed properties, which cannot fall out of sync:
@Observable
final class CartModel {
var items: [Item] = []
var total: Decimal { items.reduce(0) { $0 + $1.price } }
var isEmpty: Bool { items.isEmpty }
var badge: String? { items.isEmpty ? nil : "\(items.count)" }
}
Storing total as a var and remembering to update it in five places is how totals end up wrong. Compute it.
Observation is read-tracking. Which of these two views re-renders when count changes?
class Model {
var count = 0
var name = "unchanged"
}
let model = Model()
func viewA() -> String {
"count is \(model.count)"
}
func viewB() -> String {
"name is \(model.name)"
}
print(viewA())
print(viewB())
model.count = 5
print(viewA())
print(viewB())When should a view use @Bindable rather than a plain reference to an @Observable model?
Derive, do not store
total and count are stored and updated by hand, so they drift. Rewrite them as computed properties.
Model a search screen
Build a model with a query, a list of names, and a computed results that filters case-insensitively. Print the results for two different queries.
Sketch the model for a to-do app: the stored state, the computed properties, and the methods. Aim for the smallest possible set of stored properties, with everything else derived.
You can now:
- Write an
@Observablemodel and connect it to views - Explain per-property read tracking and why it re-renders less
- Choose between
@State,@Bindable, plain references and the environment - Prefer derived computed properties over stored duplicates
Next up: lists and navigation — where view identity starts to matter.