@Observable Cuts SwiftUI Redraws in Half
A close look at how the Observation framework tracks exactly what your view reads — and why ObservableObject never could.
In a settings screen with forty rows, every keystroke in a single text field was re-rendering the entire list. The culprit was a view model built on ObservableObject, where any change to any @Published property tells every observing view to recompute its body. Switching to the Observation framework’s @Observable macro dropped body evaluations on that screen by more than half.
The difference is granularity. ObservableObject publishes a single objectWillChange signal for the whole object, so SwiftUI can’t tell which property moved. With @Observable, SwiftUI records the specific properties a view’s body actually reads, and only invalidates that view when one of those changes. A row that only shows username no longer cares that bio was edited.
The migration
The mechanical change is small. Drop the protocol conformance and the @Published wrappers, add the macro, and swap the property wrappers at the call site:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Observation
@Observable
final class ProfileModel {
var username = ""
var bio = ""
var isSaving = false
@ObservationIgnored
var analyticsToken: String? // never triggers a redraw
}
struct ProfileScreen: View {
@State private var model = ProfileModel()
var body: some View {
Form {
UsernameRow(model: model)
BioEditor(model: model)
}
}
}
Owned models move from @StateObject to @State. Models passed down need no wrapper at all — a plain let or var is enough, because tracking happens on access, not on declaration. When a child needs a binding, use @Bindable locally.
Three gotchas
Initialisation cost. @State may call your initialiser more than once as the view struct is recreated, though only the first instance is kept. Keep init cheap and move network work into .task.
Computed properties still track. A computed property that reads username makes the view depend on username. That’s usually what you want, but it can surprise you when a “cheap” computed value touches half the model.
Deployment target. Observation requires iOS 17. If you still support iOS 16, keep ObservableObject for now — the two can coexist in the same project while you migrate screen by screen.
Measure before and after with Self._printChanges() inside body or the SwiftUI template in Instruments. On our settings screen the numbers spoke for themselves.
— 30 —
Contacts-Style Alphabet Index
Add the classic A–Z section index to a SwiftUI List with sectionIndexLabel — no UIKit bridge required.
Surviving Swift 6's Sendable Warnings
Strict concurrency lit up 300 warnings overnight. A triage order that clears most of them in a day.