Refactoring SwiftUI State with Observation
The Observation framework is often introduced as a shorter replacement for ObservableObject and @Published. That is true, but it misses the architectural benefit. SwiftUI can track the observable properties a view reads while evaluating body, then invalidate the views that depend on those properties. The result can be more precise than broadcasting every change through one object-wide publisher.
That precision does not remove the need for design. A useful migration still begins with three questions: who owns this state, who merely reads it, and who is allowed to change it?
Make ownership explicit
In this example, the screen creates and owns its model, so the model is stored with @State. The @Observable macro supplies property-access tracking, while @MainActor makes the UI isolation rule explicit.
import Observation
import SwiftUI
@Observable
@MainActor
final class ReadingList {
var books: [String] = []
var draft = ""
var canAdd: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func addBook() {
guard canAdd else { return }
books.append(draft)
draft = ""
}
}
struct ReadingListView: View {
@State private var model = ReadingList()
var body: some View {
@Bindable var model = model
Form {
TextField("Book title", text: $model.draft)
Button("Add", action: model.addBook)
.disabled(!model.canAdd)
ForEach(model.books, id: \.self) { book in
Text(book)
}
}
}
}
The local @Bindable variable does not become a second owner. It creates bindings for the observable properties that this view edits. @State still preserves the model across view reconstruction. That distinction matters: replacing every old wrapper mechanically can create multiple sources of truth even when the code compiles.
If a model comes from a parent, accept it as a normal property. Add @Bindable only where a child needs writable bindings. If a value is global to a subtree, the environment can be appropriate, but it should not become a shortcut for passing every dependency implicitly.
Pass less to child views
Observation makes a large object cheaper to observe; it does not make a large object easier to understand. A child that only displays the number of books can receive an integer instead of the complete model:
struct BookCount: View {
let count: Int
var body: some View {
Text("\(count) books")
.foregroundStyle(.secondary)
}
}
Narrow inputs document what the child needs. They also make previews trivial and reduce the temptation to let an unrelated child mutate shared state. Give the full model only to a component that genuinely coordinates several of its properties.
Keep effects replaceable
An observable UI model may coordinate loading, but it should not hide an irreplaceable global network client. Define a small client interface, perform the request at that boundary, and update the main-actor model with the returned value. This separation lets a unit test construct ReadingList, call addBook(), and assert its synchronous rules without a network, clock, or database.
It also prevents another common mistake: launching unstructured tasks from property observers. Cancellation and lifetime are clearer when asynchronous work begins from a view task, an explicit model method, or a dedicated service whose ownership is known.
Migrate in slices
A safe migration can start at a leaf screen:
- Mark the model with
@Observableand remove property-level@Publishedannotations. - Store a locally owned reference model with
@State. - Introduce
@Bindableonly at controls that require writable bindings. - Pass simple values to display-only children.
- Re-run interaction tests and inspect invalidations for frequently updated screens.
Do not mix this work with an unrelated navigation or persistence rewrite. Small slices make it easier to identify whether a behavior change came from ownership, concurrency isolation, or Observation itself.
Inspect the actual dependency surface
After migrating, inspect which values cause frequently used views to update. SwiftUI’s change diagnostics and Instruments can help during development. A timer, download progress value, or search draft should not invalidate a large area that never reads it. If updates remain broad, look for a parent that reads the whole model and redistributes derived values before splitting types at random.
Split a model according to ownership and lifetime. Session state, a document being edited, and transient presentation state normally have different owners even if one screen currently displays all three. Splitting only to reduce line counts can create several objects that must always change together, which is a less honest boundary.
Environment injection also deserves a deliberate rule. It is convenient for an application session or a well-defined feature subtree, but it hides the dependency from an initializer. Reusable leaf components are often clearer with explicit values or bindings. Whichever mechanism is chosen, a reader should be able to locate where the model is created, how long it lives, and who resets it when an account or document closes.
Observation is a better tracking mechanism, not an architecture in a macro. The durable improvement comes from pairing it with clear ownership, narrow view inputs, main-actor UI state, and replaceable effect boundaries.