Swift’s new Observation system - why you should switch (and how)

TL;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

  1. Finer-grained invalidation (fewer wasted re-renders): ObservableObject is coarse: any change fires objectWillChange and 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.
  2. Way less boilerplate: No ObservableObject, no @Published, no Combine imports, no manual objectWillChange. You write plain stored vars — the compiler synthesizes the tracking.
  3. 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.
  4. Cleaner bindings: @Bindable gives you $model.name for any @Observable (or SwiftData @Model) instance you pass into a view. It feels like @State, but for your own types.
  5. SwiftData synergy: SwiftData models (@Model) are already observation-ready. You can bind directly to them with @Bindable and query with @Query — zero glue code.
  6. Modern concurrency: You aren’t juggling Combine threads. Mark your view models @MainActor where 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:


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)

  1. Start new screens with Observation: Leave old screens on ObservableObject; both models co-exist fine in one app.
  2. Convert VMs opportunistically:

    • Replace ObservableObject with @Observable.
    • Remove @Published — use stored vars.
    • Replace @StateObject with @State (or pass instances through initializers/Environment).
  3. 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.

  4. Exclude non-state: Wrap services, caches, or tasks with @ObservationIgnored so they don’t affect invalidation or trigger rebuilds.
  5. Batch writes: If a user action flips multiple fields, mutate them within one action and (for SwiftData) save once to reduce churn.
  6. 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


Common pitfalls (and quick fixes)


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?