Why @Observable Changes Everything in SwiftUI
If you've been building SwiftUI apps since the early days, you know the pain. ObservableObject, @Published, @StateObject, @ObservedObject, @EnvironmentObject — a sprawl of property wrappers that confused beginners and tripped up even experienced developers. And the real kicker? Any change to any @Published property would invalidate every view subscribed to that object, even views that never touched the changed property.
Apple finally addressed all of this with the Observation framework, introduced at WWDC 2023 and available from iOS 17. At its heart sits the @Observable macro — a compile-time transformation that gives SwiftUI property-level tracking, ditches the Combine dependency under the hood, and dramatically simplifies the whole property wrapper situation.
With Swift 6.2 now shipping the Observations async sequence, the framework has honestly reached full maturity.
This guide covers everything you need to know: how the macro works internally, how to migrate existing code, which property wrapper to use in each scenario, performance gains you can actually measure, strict concurrency integration, the new Swift 6.2 streaming API, testing strategies, and the common mistakes that still catch developers off guard.
How @Observable Works Under the Hood
Understanding what happens at compile time helps you debug issues faster and write more intentional code. When you annotate a class with @Observable, the Swift compiler performs a macro expansion that essentially rewrites your type behind the scenes.
The Macro Expansion
You can actually see the generated code in Xcode — just right-click the macro and select Expand Macro. Here's a simplified view of what gets produced:
@Observable
final class CounterViewModel {
var count = 0
var label = "Taps"
}
// Expands roughly to:
final class CounterViewModel: Observable {
@ObservationTracked var count = 0
@ObservationTracked var label = "Taps"
@ObservationIgnored private let _$observationRegistrar
= Observation.ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<CounterViewModel, Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<CounterViewModel, Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
Three critical pieces show up here:
- ObservationRegistrar — this is the engine that records which properties are read and notifies when they change.
- @ObservationTracked — a peer macro applied to every stored property. Its getter calls
access(keyPath:)to record reads, and its setter callswithMutation(keyPath:)to broadcast writes. - Observable protocol conformance — added automatically, which is how SwiftUI recognizes the type.
withObservationTracking — The Subscription Mechanism
The Observation framework exposes a global function, withObservationTracking(_:onChange:). It takes two closures:
- apply — executes immediately. Every
@Observableproperty accessed inside this closure gets recorded. - onChange — fires once the next time any tracked property changes (with
willSetsemantics — so you see the old value, not the new one).
withObservationTracking {
// Accessing .count registers it for tracking
print(viewModel.count)
} onChange: {
// Called once when .count changes — must re-register to keep watching
print("count is about to change")
}
Now here's where it gets interesting. SwiftUI uses a more powerful internal variant of this mechanism. When it renders a view's body, it wraps the call in observation tracking. Every @Observable property the body reads gets registered. When any of those specific properties mutate, SwiftUI invalidates only that view — not every view that happens to reference the object.
That's a huge deal for performance, as we'll see shortly.
Migration from ObservableObject — Step by Step
Moving from the old Combine-based pattern to Observation is honestly pretty straightforward once you know the mapping. Let me walk you through a before-and-after comparison.
Before (ObservableObject + Combine)
class ProfileViewModel: ObservableObject {
@Published var name = ""
@Published var bio = ""
@Published var avatarURL: URL?
func save() async throws { /* ... */ }
}
struct ProfileView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
TextField("Bio", text: $viewModel.bio)
}
}
}
struct ProfileDetailView: View {
@ObservedObject var viewModel: ProfileViewModel
var body: some View {
Text(viewModel.name)
}
}
struct AppRootView: View {
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
ProfileDetailView(viewModel: viewModel)
.environmentObject(viewModel)
}
}
After (@Observable)
@Observable
final class ProfileViewModel {
var name = ""
var bio = ""
var avatarURL: URL?
func save() async throws { /* ... */ }
}
struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
Form {
TextField("Name", text: $viewModel.name)
TextField("Bio", text: $viewModel.bio)
}
}
}
struct ProfileDetailView: View {
// No wrapper needed for read-only access
let viewModel: ProfileViewModel
var body: some View {
Text(viewModel.name)
}
}
struct AppRootView: View {
@State private var viewModel = ProfileViewModel()
var body: some View {
ProfileDetailView(viewModel: viewModel)
.environment(viewModel)
}
}
See how much cleaner that is? The amount of boilerplate you can strip away is pretty satisfying.
Migration Cheat Sheet
- Remove
ObservableObjectconformance → add@Observable. - Delete every
@Publishedannotation — all stored properties are tracked by default. - Replace
@StateObjectwith@State(for the owning view). - Replace
@ObservedObjectwith a plainletorvar(read-only) or@Bindable(two-way binding). - Replace
@EnvironmentObjectwith@Environment(MyType.self). - Replace
.environmentObject(obj)with.environment(obj).
Property Wrappers — Which One When
The Observation framework collapses the old zoo of wrappers into a much cleaner set. Here's the definitive decision guide.
@State — View Owns the Instance
Use @State when the current view creates and owns the @Observable object. SwiftUI preserves the instance across view redraws, just as it used to with @StateObject.
struct CounterView: View {
@State private var viewModel = CounterViewModel()
var body: some View {
Button("Count: \(viewModel.count)") {
viewModel.count += 1
}
}
}
Plain Property — View Receives the Instance (Read-Only)
When a parent passes an observable down and the child only reads its properties, you don't need any wrapper at all. SwiftUI automatically tracks property access in the body.
struct CounterLabel: View {
let viewModel: CounterViewModel // no wrapper
var body: some View {
Text("Count is \(viewModel.count)")
}
}
This was one of those "wait, really?" moments for me when I first tried it. No wrapper. Just a plain let. It just works.
@Bindable — View Needs Two-Way Binding
@Bindable is specifically for creating bindings ($viewModel.property) from @Observable classes. Use it in child views that need to write back to the model.
struct NameEditor: View {
@Bindable var viewModel: ProfileViewModel
var body: some View {
TextField("Name", text: $viewModel.name)
}
}
Quick note: @Binding is still the right choice for value types (structs, enums). @Bindable is specifically for @Observable reference types.
@Environment — Shared Across the View Hierarchy
Inject an observable into the environment at a high level, then access it anywhere below without threading it through every initializer.
// Injection
ContentView()
.environment(appSettings)
// Consumption
struct SettingsView: View {
@Environment(AppSettings.self) private var settings
var body: some View {
// For bindings from @Environment, use local @Bindable
@Bindable var settings = settings
Toggle("Dark Mode", isOn: $settings.isDarkMode)
}
}
That local @Bindable var settings = settings pattern looks a bit odd at first, but it's the idiomatic way to get bindings from environment-injected observables. You get used to it quickly.
@ObservationIgnored — Opting Out
Some properties shouldn't trigger view updates — cached data, internal services, or anything that changes frequently but isn't relevant to the UI. Mark them with @ObservationIgnored.
@Observable
final class FeedViewModel {
var posts: [Post] = []
@ObservationIgnored
private var networkService = NetworkService()
@ObservationIgnored
var lastFetchTimestamp: Date?
}
Performance Gains — Why the Difference Is Real
The performance improvement isn't theoretical. It's something you can actually measure.
With ObservableObject, a change to any @Published property would cause every subscribing view to re-evaluate its body. Picture a form with ten fields — a single keystroke in one field could trigger ten body evaluations. That's wasteful.
With @Observable, SwiftUI tracks exactly which properties each view's body accesses. A view that only reads .name won't re-render when .bio changes. Benchmarks consistently show 20–30% fewer view redraws in typical form-heavy screens, with corresponding drops in CPU usage and frame time.
The efficiency gain really compounds in lists. Consider a List displaying 500 items, each backed by an @Observable model. When one item's title changes, only that single row's body is re-evaluated — not all 500. Under the old system, object-level tracking often caused cascading invalidations that made large lists feel sluggish.
Strict Concurrency and @Observable in Swift 6
Swift 6 enforces strict concurrency checking by default, and the Observation framework intersects with this in some important ways you need to be aware of.
@MainActor Isolation
In Swift 6, SwiftUI views no longer implicitly inherit @MainActor isolation from property wrappers. If your view model drives UI (and most do), mark either the class or its UI-facing properties with @MainActor:
@MainActor
@Observable
final class DashboardViewModel {
var metrics: [Metric] = []
var isLoading = false
func refresh() async {
isLoading = true
metrics = await MetricsService.fetch()
isLoading = false
}
}
A type annotated with @MainActor is automatically Sendable, because its state is protected by the main actor's isolation domain. You don't need to add explicit Sendable conformance — that tripped me up the first time I migrated a project to strict concurrency.
Swift 6.2: Approachable Concurrency
Swift 6.2 eases the annotation burden significantly. With the defaultIsolation build setting set to MainActor, your entire module runs on the main actor by default — no need to annotate every view or view model individually. You only opt out with @concurrent for CPU-bound work that should leave the main thread:
// With defaultIsolation = MainActor, this is implicitly @MainActor
@Observable
final class SearchViewModel {
var query = ""
var results: [SearchResult] = []
func search() async {
let data = await performNetworkRequest(query: query)
// Heavy parsing happens off the main actor
let parsed = await parseResults(data)
results = parsed
}
@concurrent
private func parseResults(_ data: Data) throws -> [SearchResult] {
// Runs off the main actor
try JSONDecoder().decode([SearchResult].self, from: data)
}
}
This is a much nicer developer experience. Instead of sprinkling @MainActor everywhere, you only annotate the exceptions.
Swift 6.2: The Observations Async Sequence
This is (in my opinion) the biggest addition to the Observation framework since its introduction. Observations (SE-0475) gives you an AsyncSequence that streams values whenever tracked @Observable properties change. It's essentially a Combine-like stream without Combine — the feature developers have been asking for since day one.
Basic Usage
@Observable
final class SceneModel {
var title = ""
var selectedTab = 0
var jsonData: Data {
// Computed property aggregating state for persistence
try! JSONEncoder().encode(["title": title, "tab": "\(selectedTab)"])
}
}
// Stream changes to persist scene state
let sceneModel = SceneModel()
let stateStream = Observations { sceneModel.jsonData }
Task {
for await data in stateStream {
try await PersistenceManager.save(data)
}
}
Transactional Updates
Observations batches synchronous mutations into a single emission. If you update title and selectedTab in the same synchronous scope, only one value goes out — the final, consistent snapshot. The transaction boundary is the next await that suspends.
// Both changes produce a single emission
sceneModel.title = "Dashboard"
sceneModel.selectedTab = 2
// ^ Only one value emitted with both changes applied
This batching behavior is really thoughtful — it prevents the kind of intermediate-state bugs that plagued Combine publishers.
Key Differences from withObservationTracking
- Continuous —
Observationskeeps emitting, unlikewithObservationTrackingwhich fires just once. - Did-set semantics — you receive values after mutation, so you always see the new state.
- Async-native — integrates naturally with
for awaitloops, structured concurrency, and task cancellation. - Initial value — the first emission contains the current value at the time of subscription.
Platform Availability
Observations requires iOS 26 / macOS 26 or later for Apple platforms. However, since it's part of the Swift 6.2 toolchain, it's available on Linux and other Swift-supported platforms today. For older Apple OS versions, Point-Free's Perception library provides an equivalent called Perceptions.
Testing @Observable View Models
With Combine gone from the equation, the old @Published + sink testing pattern no longer applies. The good news? Testing actually gets simpler.
Direct Property Assertions
The simplest approach: call a method, then assert on the property. Because @Observable properties are plain stored properties under the hood, this just works:
import Testing
@Suite
struct CounterViewModelTests {
@Test func incrementUpdatesCount() {
let vm = CounterViewModel()
vm.increment()
#expect(vm.count == 1)
}
@Test func resetClearsCount() {
let vm = CounterViewModel()
vm.count = 5
vm.reset()
#expect(vm.count == 0)
}
}
No publishers, no sinks, no cancellables. Just plain property access.
Testing Async Mutations with withObservationTracking
When testing async methods that mutate observable state, you can use withObservationTracking to verify that changes fire correctly:
import Testing
import Observation
@Suite
struct ProfileViewModelTests {
@Test func loadProfileUpdatesName() async {
let vm = ProfileViewModel(service: MockProfileService())
await confirmation { confirmed in
withObservationTracking {
_ = vm.name // Register observation
} onChange: {
confirmed() // Change detected
}
await vm.loadProfile()
}
#expect(vm.name == "John Doe")
}
}
Testing with Observations (Swift 6.2)
The new Observations async sequence makes streaming assertions cleaner:
@Test func searchResultsStream() async {
let vm = SearchViewModel(service: MockSearchService())
let stream = Observations { vm.results }
Task { await vm.search(query: "SwiftUI") }
var emissions = [SearchResult]()
for await results in stream.prefix(2) {
emissions.append(contentsOf: results)
}
#expect(emissions.count > 0)
}
@MainActor and Test Performance
One thing to watch out for: marking view models with @MainActor forces all Swift Testing tests touching those models onto the main actor, which prevents parallel execution. For large test suites, consider structuring your view models so the heavy business logic lives in non-isolated helper types, and only the thin UI-facing layer carries @MainActor.
It's a small architectural trade-off, but it can meaningfully speed up your test runs.
Common Mistakes and How to Avoid Them
1. Using @State in Child Views
This one bites a lot of people. If a parent creates the observable and a child also wraps it in @State, SwiftUI preserves the child's initial copy. The child stops reflecting the parent's changes entirely. The rule is simple: only the creating view uses @State.
// Wrong — child preserves stale instance
struct ChildView: View {
@State var viewModel: CounterViewModel // Bug!
// ...
}
// Correct
struct ChildView: View {
let viewModel: CounterViewModel // Read-only, or @Bindable for bindings
// ...
}
2. Forgetting @Bindable for Bindings
When a child needs $viewModel.property, a plain let won't compile. You need @Bindable. The compiler error message here is actually decent, so you'll catch it quickly — but it's worth knowing upfront.
3. Observing the Object Instead of Its Properties
.onChange(of: viewModel) watches the object reference, not property values. It'll never fire for property mutations on the same instance. Track specific properties instead: .onChange(of: viewModel.count).
4. Heavy Computed Properties in the Body
Since SwiftUI tracks every property access in body, accessing a computed property that reads ten stored properties registers all ten for observation. That can negate the performance benefits we talked about earlier. Keep body reads minimal or use @ObservationIgnored for internal state that doesn't need to drive the UI.
5. Mixing @Observable with ObservableObject
While both can coexist in a project, don't mix them on the same type. Pick one pattern per class. Gradual migration — converting one view model at a time — is the recommended approach, and honestly it works really well in practice.
Frequently Asked Questions
Can I use @Observable with structs?
No. The @Observable macro only works with classes. Structs are value types and SwiftUI already tracks their mutations through @State and @Binding without needing observation. If you need observable behavior, use a class.
Is @Observable compatible with iOS 16 or earlier?
The Observation framework requires iOS 17 / macOS 14 as a minimum deployment target. If you still need to support iOS 16, you'll have to stick with ObservableObject. Point-Free's Perception library offers a back-port that mirrors the @Observable API for older OS versions — it's a solid option for bridging the gap.
Does @Observable replace Combine entirely?
Not entirely. @Observable replaces the need for ObservableObject, @Published, and the Combine-based data flow within SwiftUI. However, Combine still has its place for reactive stream operations like debouncing, throttling, merging publishers, and handling complex async pipelines that go beyond simple property observation.
That said, with Observations in Swift 6.2, the overlap is growing.
Do I need to update @Observable properties on the main thread?
While the ObservationRegistrar itself is thread-safe, SwiftUI expects UI-driving state to be updated on the main thread. In practice, just mark your view models with @MainActor — especially under Swift 6 strict concurrency — to guarantee safe UI updates and eliminate data race warnings.
What is the difference between @Bindable and @Binding?
@Binding is for value types — it creates a two-way connection to a source of truth for structs, enums, or primitives. @Bindable is specifically for @Observable reference types — it enables the $ syntax to create bindings to the properties of an observable class. They solve the same problem (two-way data flow) but for different type categories.