5.4 — @Observable & Swift 6
Opening scenario
You open a SwiftUI codebase from 2022. Every view model looks like:
@MainActor
final class FeedViewModel: ObservableObject {
@Published var items: [Post] = []
@Published var isLoading = false
@Published var error: String?
@Published var query: String = ""
}
Every property is @Published. Every view that reads even one of these properties re-renders when any of them changes — because ObservableObject notifies on the whole object, and the view subscribes to the whole object. Searching causes the loading indicator subtree to re-render. Loading causes the search bar to re-render. Type a character — three subtrees re-render. It works, but it’s wasteful.
Then iOS 17 / Xcode 15 shipped the @Observable macro. Same view model, less ceremony, per-property observation so views only re-render when properties they actually read change:
@MainActor
@Observable
final class FeedViewModel {
var items: [Post] = []
var isLoading = false
var error: String?
var query: String = ""
}
That’s the new model. This chapter is how it works under the hood, how to migrate, and what changes in Swift 6’s strict concurrency world.
| Comparison | ObservableObject (pre-2023) | @Observable (iOS 17+) |
|---|---|---|
| Conformance | class: ObservableObject | @Observable class (macro) |
| Property annotation | @Published var x | plain var x |
| View wrapper | @StateObject / @ObservedObject / @EnvironmentObject | @State / @Bindable / @Environment |
| Granularity | Object-level (whole object invalidates) | Property-level (only readers of changed props re-render) |
| Threading | Uses Combine | Uses observation framework, no Combine |
| Concurrency | Manual @MainActor | Same, but more uniform with Swift 6 |
Concept → Why → How → Code
What @Observable does
The @Observable macro (declared in the Observation module, shipped in Foundation) expands at compile time into:
- Conformance to the
Observableprotocol - An internal observation registrar that tracks which properties were read by which observers
- Property accessors that record access on read and notify on write
You write:
@Observable
final class Counter {
var count = 0
}
The compiler generates (roughly):
final class Counter: Observable {
private let _$observationRegistrar = ObservationRegistrar()
private var _count = 0
var count: Int {
get {
_$observationRegistrar.access(self, keyPath: \.count)
return _count
}
set {
_$observationRegistrar.withMutation(of: self, keyPath: \.count) {
_count = newValue
}
}
}
}
SwiftUI’s withObservationTracking { } integration calls body, recording every observed property read during that invocation. On the next mutation of those exact properties, only views that read them get invalidated.
Per-property re-rendering — the actual win
@Observable
final class Profile {
var name = ""
var bio = ""
var avatarURL: URL?
}
struct NameView: View {
let profile: Profile
var body: some View {
Text(profile.name) // only re-renders when `name` changes
}
}
struct BioView: View {
let profile: Profile
var body: some View {
Text(profile.bio) // only re-renders when `bio` changes
}
}
Even though both views share the same Profile instance, mutating profile.bio invalidates BioView and not NameView. With ObservableObject + @Published, both views would re-render.
In a real app, this means search bars don’t blink when network requests finish, animation states don’t reset when unrelated properties change, and large lists don’t re-diff on every minor mutation.
The new property wrappers
struct CounterScreen: View {
@State private var counter = Counter() // owns the instance
var body: some View {
VStack {
Text("\(counter.count)")
Button("Increment") { counter.count += 1 }
ChildView(counter: counter) // plain pass — no wrapper needed
}
}
}
struct ChildView: View {
let counter: Counter // just a reference; observation auto-set up
var body: some View {
Text("Child sees \(counter.count)")
}
}
Key changes from the old world:
@Statereplaces@StateObjectfor owning an@Observableinstance. Yes,@Statenow works with reference types (only when they’re@Observable).- Children take the instance as a plain
letproperty. No@ObservedObjectneeded — observation registration happens automatically when the view reads a property. @Bindablereplaces@Bindingfor@Observableinstances when you need two-way bindings:
struct ProfileForm: View {
@Bindable var profile: Profile
var body: some View {
TextField("Name", text: $profile.name)
TextField("Bio", text: $profile.bio)
}
}
@Bindable projects bindings to individual properties via $ — same syntax as @State, but on a reference-type observable.
@Environment for @Observable instances
@main
struct MyApp: App {
@State private var auth = AuthService() // not @StateObject — @State
var body: some Scene {
WindowGroup {
ContentView()
.environment(auth) // not .environmentObject(_:)
}
}
}
struct ProfileView: View {
@Environment(AuthService.self) private var auth // type-based lookup
var body: some View {
Text(auth.currentUser?.name ?? "Signed out")
}
}
.environment(_:)(not.environmentObject(_:))@Environment(MyType.self)(the type, not a key path)- Like
@EnvironmentObject, crashes at runtime if missing
For optional environment (no crash), use @Environment(MyType.self) private var x: MyType?.
Migration cheat sheet
Before (ObservableObject) | After (@Observable) |
|---|---|
class X: ObservableObject | @Observable class X |
@Published var p | var p |
@StateObject private var x = X() | @State private var x = X() |
@ObservedObject var x: X | let x: X (just a property) |
@EnvironmentObject var x: X | @Environment(X.self) private var x |
.environmentObject(x) | .environment(x) |
@Binding var name: String from observable | @Bindable var model: X then $model.name |
You can migrate one file at a time — @Observable and ObservableObject coexist in the same app. Only the views observing each model need to know its style.
What @Observable is not
- Not Combine. It uses the
Observationframework, a separate, lighter mechanism. There’s noobjectWillChangepublisher. - Not a property wrapper. It’s a macro that generates observation. No
@Published, no_$storage you should touch. - Not value-type. Still requires a class. Structs use
@State. - Not magic. It tracks property reads/writes; it does not track computed property dependencies. If a computed property reads stored properties internally, observation works through it. If it reads external state (singletons, globals), it doesn’t.
Working with Swift 6 concurrency
Swift 6 enables strict concurrency checking. SwiftUI’s View.body is @MainActor-isolated. Combine this with @Observable:
@MainActor
@Observable
final class FeedModel {
var items: [Post] = []
var isLoading = false
func load() async {
isLoading = true
defer { isLoading = false }
do {
// .task hops off main, then we hop back
let fetched = try await api.fetch()
self.items = fetched
} catch {
// handle
}
}
}
- The class is
@MainActor— all property access is checked on main load()is implicitly@MainActor(because the class is)- Inside,
awaitmay suspend; the suspension point hops off main if the awaited function is on a different actor - After resume, you’re back on main, so
self.items = fetchedis safe
The compiler will catch you if you try to mutate items from a non-main context. This is a feature, not friction — it eliminates a category of “UI updates from background threads” bugs.
For non-UI observables (e.g., a background sync service), you can omit @MainActor and use other actors:
actor SyncManager {
// ...
}
@Observable
final class SyncStatus {
var pendingCount = 0
var lastSync: Date?
}
If SyncStatus is read from SwiftUI views (@MainActor), mutate it on @MainActor. If it’s only used internally, don’t pin to @MainActor.
Performance characteristics
@Observable is generally faster than ObservableObject:
- No Combine pipeline allocation per property
- Per-property observation reduces re-render scope
- No
objectWillChange.send()call cost - Macro-generated code is dead simple — direct property access with registrar hooks
Apple’s own measurements (WWDC 2023 “Discover Observation in SwiftUI”) show 1.5-3× scroll perf improvements in lists where rows previously observed shared ObservableObjects.
Edge cases & gotchas
-
Computed properties that depend on stored ones — observation works transparently.
var fullName: String { "\(first) \(last)" }— readers offullNameare notified whenfirstorlastchanges. -
Mutating arrays/dictionaries in place — observation tracks property set, not internal mutation. If your property is
var items: [Item] = [], thenitems.append(x)triggers notification (because Swift treats the array assignment as a write toitems). If your property is a@Observable class List, then mutatinglist.add(x)triggers notification onList’s properties, not on the parent. -
@Observable+ protocols —@Observableis a macro, not a protocol you can constrain to. To pass observables polymorphically, use the underlyingObservableprotocol:
func observe(_ thing: any Observable) { /* ... */ }
-
Subclassing — works, but subclass should also be
@Observableif it adds observable properties. -
Properties you don’t want observed — mark with
@ObservationIgnored:
@Observable
final class Model {
var displayedValue = ""
@ObservationIgnored
var lastFetchedAt: Date? // mutations don't notify
}
Use for caches, instrumentation, things that aren’t UI-visible.
In the wild
- Apple’s own apps built/updated for iOS 17+ use
@Observableexclusively for new model code. Translate, Journal, Sandbox. - The Apple Sample Code repository — every new SwiftUI sample since 2023 uses
@Observable. - Point-Free’s TCA (Composable Architecture) released a
@Observable-friendly variant in 2024 — their@ObservableStatemacro is conceptually similar. - Soroush Khanlou’s open-source apps migrated their
ObservableObjectview models to@Observableand reported measurable scroll perf wins in chat list views.
Common misconceptions
- “
@Observablereplaces everything fromObservableObject.” Not quite —@Observablerequires iOS 17+. If you support iOS 16 or earlier, you still needObservableObject. Many production apps run both. - “
@Bindableis the same as@Binding.” No.@Bindingis for value-typed@Statepassed from a parent.@Bindableis for@Observablereference-typed instances to project bindings to their properties. Different mechanism. - “
@Observablemakes my class thread-safe.” No. It tracks observation, not concurrency. Use@MainActor(for UI-bound) or actors (for shared mutable state). - “
@Stateis now for everything.”@Stateworks for value types (as before) and for@Observableinstances. It does not work for plain reference types — they still need to be@Observablefor observation to work. - “I have to migrate everything to
@Observableimmediately.” No. They interoperate. Migrate file by file as you touch each.
Seasoned engineer’s take
When I greenfield a SwiftUI app in 2026 with iOS 17+ minimum, I use @Observable exclusively. There’s almost no reason to reach for ObservableObject in new code. The main reasons I keep ObservableObject around:
- iOS 16 support — drops
@Observableoff the table - Combine integration — if I’m already using Combine pipelines that feed
@Published(rare in 2026 —AsyncSequencecovers most cases) - A monolithic legacy view model that touches 200 things — wait until the next major refactor
For migration: I do it lazily — when I touch a view model for an unrelated reason, I migrate it as part of that PR. Trying to mass-migrate is a project that gets abandoned.
The @Observable thing I most often see misused: people put @Observable on a class but then read it from a non-SwiftUI context expecting Combine semantics. There is no $property Combine publisher; observation is SwiftUI-scoped. For non-SwiftUI reactive needs, use AsyncSequence or Observation.withObservationTracking { } directly.
TIP: When migrating, search the codebase for
@Publishedand@StateObject— those are your migration targets.@ObservedObjectand@EnvironmentObjectget replaced by plain property access and@Environment(Type.self)respectively.
WARNING:
@Observablerequires the class to be a class. Marking a struct with@Observableis a compile error. Some folks try to make their value-typed models@Observable; they need@Stateinstead, which works fine for structs.
Interview corner
Junior-level: “What does the @Observable macro do?”
It’s a Swift 5.9+ macro that conforms a class to the Observable protocol and wraps each stored property in an accessor that tracks reads and writes. SwiftUI observes those property accesses to figure out which views need to re-render when a property changes — at per-property granularity, rather than the whole-object invalidation of ObservableObject.
Mid-level: “Why migrate from ObservableObject to @Observable? What’s the practical difference?”
ObservableObject with @Published causes any view subscribing to the object to re-render when any @Published property changes. @Observable tracks which views read which properties, and only invalidates the views that actually read the changed property. In practice, lists and forms with many fields gain noticeable scroll/edit perf. Migration is mostly mechanical: drop @Published, change the class to @Observable, change view wrappers (@StateObject → @State, @ObservedObject → plain prop, @EnvironmentObject → @Environment(Type.self), @Binding from observable → @Bindable).
Senior-level: “How does SwiftUI know which properties a view reads, given that @Observable doesn’t use Combine?”
SwiftUI invokes body inside a call to withObservationTracking { ... } onChange: { ... } (Observation framework). The withObservationTracking block records, via the property accessors generated by the @Observable macro, every observable property access (calls to registrar.access(self, keyPath:)). When the block completes, SwiftUI has a set of (instance, keyPath) pairs that this body invocation depends on. On the next mutation of any of those pairs (caught by registrar.withMutation(...)), SwiftUI invalidates just the views whose recorded set included that pair, scheduling them for re-render. The result is per-property fine-grained invalidation without Combine subscriptions.
Red flag in candidates: Saying “@Observable is just syntax sugar over ObservableObject.” Indicates they haven’t actually used it. The mechanisms are entirely different and the perf characteristics differ.
Lab preview
Every Phase 5 lab uses @Observable for view models. Lab 5.1 is the first hands-on with the new property wrappers, including @Bindable in the edit screen.
Next: Navigation