Swift’s new Observation system - why you should switch (and how)
01 Sep 2025TL;DR: Drop ObservableObject, @StateObject, and @Published for new code.
Apple’s Observation (@Observable, @Bindable, @ObservationIgnored) gives you property-level updates, less boilerplate, better performance, and cleaner bindings — and it meshes perfectly with SwiftData. Below is how to use it to make your app simpler and faster.
Why Observation beats the old model
- Finer-grained invalidation (fewer wasted re-renders):
ObservableObjectis coarse: any change firesobjectWillChangeand can over-invalidate views. Observation tracks stored properties, so only views that read the changed fields re-render. That means smoother lists, forms, and detail screens. - Way less boilerplate: No
ObservableObject, no@Published, no Combine imports, no manualobjectWillChange. You writeplain stored vars— the compiler synthesizes the tracking. - Works with structs or classes: The old model forced class-based view models. Observation supports structs too, which is great for small models and pure value semantics.
- Cleaner bindings:
@Bindablegives you$model.namefor any@Observable(or SwiftData@Model) instance you pass into a view. It feels like@State, but for your own types. - SwiftData synergy: SwiftData models (
@Model) are already observation-ready. You can bind directly to them with@Bindableand query with@Query— zero glue code. - Modern concurrency: You aren’t juggling Combine threads. Mark your view models
@MainActorwhere appropriate and lean on Swift 6 strict concurrency.
When to keep the old stuff: If you must support iOS 14–16 or you’re deeply invested in Combine pipelines today. Otherwise, switch.
Side-by-side: old vs. new
Before (Combine/ObservableObject):
import Combine
import SwiftUI
final class ProfileVM: ObservableObject {
@Published var name = ""
@Published var age = 0
private let api: API
init(api: API) { self.api = api }
func load() {
// fetch, then assign to @Published props
}
}
struct ProfileView: View {
@StateObject private var vm: ProfileVM
init(api: API) {
_vm = StateObject(wrappedValue: ProfileVM(api: api))
}
var body: some View {
Form {
TextField("Name", text: $vm.name)
Stepper("Age \(vm.age)", value: $vm.age)
}
}
}
After (Observation):
import Observation
import SwiftUI
@Observable
@MainActor
final class ProfileVM {
var name = ""
var age = 0
@ObservationIgnored private let api: API
init(api: API) { self.api = api }
func load() async {
// await api.fetch()
// name = ...
// age = ...
}
}
struct ProfileView: View {
// With Observation you don't need @StateObject.
// Keep a stable instance using @State to pin identity.
@State private var vm: ProfileVM
init(api: API) {
_vm = State(initialValue: ProfileVM(api: api))
}
var body: some View {
Form {
// clean bindings via Observation
TextField("Name", text: $vm.name)
Stepper("Age \(vm.age)", value: $vm.age)
Button("Reload") { Task { await vm.load() } }
}
}
}
What changed:
- No
ObservableObject/@Published. - No Combine.
@ObservationIgnoredexcludes dependencies from the reactive graph.@Stateholds the instance; Observation handles the reactivity.
Binding to models with @Bindable (no extra view model)
If your type is the state, pass it straight into the view and mark it @Bindable. You get field bindings for free:
import Observation
import SwiftUI
@Observable
struct Todo: Identifiable {
var id = UUID()
var title: String
var done = false
}
struct TodoRow: View {
@Bindable var todo: Todo // enables $todo.title, $todo.done
var body: some View {
Toggle(todo.title, isOn: $todo.done)
}
}
struct TodoList: View {
@State private var items: [Todo] = [
.init(title: "Ship Observation"),
.init(title: "Remove @Published")
]
var body: some View {
List($items) { $item in // bind each element
TodoRow(todo: item)
}
.toolbar {
Button("Add") { items.append(.init(title: "New")) }
}
}
}
Why it’s better: The row invalidates only when that row’s fields change. No global objectWillChange cascade.
SwiftData + Observation = chef’s kiss
SwiftData models are automatically observable. Use @Bindable for editing, @Query for live-updating lists.
import SwiftData
import SwiftUI
@Model
final class Note {
var title: String
var body: String
var createdAt: Date = .now
init(title: String, body: String) {
self.title = title; self.body = body
}
}
struct NotesList: View {
@Query(sort: \.createdAt, order: .reverse) private var notes: [Note]
var body: some View {
List(notes) { note in
NavigationLink(note.title) { NoteDetail(note: note) }
}
}
}
struct NoteDetail: View {
@Environment(\.modelContext) private var context
@Bindable var note: Note
var body: some View {
Form {
TextField("Title", text: $note.title)
TextEditor(text: $note.body)
Button("Delete") { context.delete(note) }
}
}
}
Tip: For UI-only flags on a SwiftData model (expanded rows, selection), mark them @Transient so they’re observable but not persisted.
Migration playbook (incremental and safe)
- Start new screens with Observation: Leave old screens on ObservableObject; both models co-exist fine in one app.
-
Convert VMs opportunistically:
- Replace
ObservableObjectwith@Observable. - Remove
@Published— use stored vars. - Replace
@StateObjectwith@State(or pass instances through initializers/Environment).
- Replace
-
Stabilize lifetimes: Observation won’t keep your instance alive for you. Pin identity with:
@State private var vm = MyVM(dep: dep)or initialize in the view’s init. Do not re-create the VM in body.
- Exclude non-state: Wrap services, caches, or tasks with
@ObservationIgnoredso they don’t affect invalidation or trigger rebuilds. - Batch writes: If a user action flips multiple fields, mutate them within one action and (for SwiftData) save once to reduce churn.
- Threading and Swift 6: Mark UI VMs
@MainActor. For heavy work, hop to background actors and return results to the main actor for assignment.
Patterns that improve real-world performance
- Row-local observation in big lists: Bind list elements (
List($items)) and use@Bindablein rows so toggling one item doesn’t repaint the world. - Derived collections computed on demand: Keep source-of-truth arrays as stored vars; compute filtered/sorted views as
varcomputed properties. If expensive, memoize or compute in a lightweight@Observablecoordinator. - Avoid “computed observables” that do heavy work: Observation tracks reads. If a computed property is costly, make the inputs observable and cache the result.
- Prefer @Observable + SwiftData over “fat view models”: Persisted data in SwiftData, coordination in a slim
@ObservableVM. Cleaner separation and testability.
Common pitfalls (and quick fixes)
- “My view model keeps re-initializing.” - You created it in body. Move creation to init or store it in
@State. - “Why did changing a service trigger a re-render?” - You forgot
@ObservationIgnoredon non-UI dependencies. - “Bindings don’t appear for my type.” - Make the type
@Observable(or@Model) and use@Bindablein the receiving view. - “We support iOS 16” - Keep
ObservableObjecton those screens, or ship alternate implementations via#available. Observation requires iOS 17+.
A small, opinionated template
Use this as your default stack for new features:
// Domain/persistence
@Model final class Entity { /* fields */ }
// Coordination / derived state
@Observable @MainActor
final class FeatureVM {
@ObservationIgnored private let context: ModelContext
var query = ""
init(context: ModelContext) { self.context = context }
var results: [Entity] {
// Build a FetchDescriptor based on `query`
// return (try? context.fetch(desc)) ?? []
[]
}
func add(/*…*/) { /* insert + save once */ }
}
// Views
struct FeatureScreen: View {
@Environment(\.modelContext) private var context
@State private var vm: FeatureVM
init(context: ModelContext? = nil) {
// If not provided, we'll get it from the Environment in body on first run.
_vm = State(initialValue: FeatureVM(context: context ?? .init()))
}
var body: some View {
VStack {
TextField("Search…", text: $vm.query)
List(vm.results) { item in
NavigationLink(/*…*/) { /* detail */ }
}
Button("Add") { vm.add() }
}
}
}
Final take
If you’re targeting iOS 17+ Observation is the correct default. It’s simpler than ObservableObject, scales better in lists and forms, and it clicks perfectly with SwiftData. Start migrating screen-by-screen: pin VM lifetimes with @State, mark non-state as @ObservationIgnored, and lean into @Bindable for ergonomic bindings. You’ll ship less code, with fewer bugs, and your UI will feel snappier — what’s not to like?