How to use @Observable Macro in SwiftUI | SwiftUI Bootcamp #76
@Observable is the modern replacement for ObservableObject + @Published, introduced in iOS 17. It eliminates boilerplate by making every stored property in your class automatically observable — views only re-render when properties they actually use change, which is a significant performance improvement over the old approach.
What You'll Learn
- How
@ObservablereplacesObservableObject+@Publishedwith zero boilerplate - How to share an observable model with child views using direct injection (
@Bindable) versus environment injection (@Environment) - Why
@Observableonly re-renders views that read a changed property — not all views that have access to the object
Mental Model
Imagine a city-wide notification system. The old system (ObservableObject) sends an alert to the entire city every time anything changes — even if your neighborhood is unaffected. @Observable is like a targeted notification system: it knows exactly which neighborhoods (views) care about which pieces of information, and only sends alerts to the ones that matter.
A view reading viewModel.title is "subscribed" to exactly that property. If viewModel.count changes but the view never reads count, the view isn't re-rendered at all. This is fine-grained observation, and it makes large view trees significantly more efficient.
Detailed Explanation
@Observable is a Swift macro introduced in iOS 17 / Swift 5.9. When you annotate a class with @Observable, the macro synthesizes observation tracking for all stored properties automatically. You no longer need @Published on each property — all stored properties are observable by default. Mark a property with @ObservationIgnored to opt it out of tracking.
The three ways to use an @Observable object in SwiftUI:
@State var viewModel = ViewModel()in the root view — creates and owns the object. SwiftUI keeps it alive for the lifetime of the view and treats it like a reference type owned by the view.@Bindable var viewModel: ViewModelin a child view — receives the model and can createBindingvalues from its properties using$viewModel.someProperty. Use this when the child needs to write back to the model.@Environment(ViewModel.self) var viewModelin a descendant view — receives the model through the environment, no explicit prop-passing needed. The root must inject it with.environment(viewModel). Use this for deeply nested views that shouldn't require the model to be threaded through every level.
The old pattern @StateObject, @ObservedObject, and @EnvironmentObject still work but are no longer needed for new code. Minimum deployment target: iOS 17 for @Observable.
Code Structure
ObservableBootcamp.swift demonstrates all three usage patterns in one file. ObservableViewModel is the model. ObservableBootcamp owns it with @State. SomeChildView receives it with @Bindable for mutation. SomeThirdView receives it through @Environment — showing how the model can reach deeply nested views without direct injection.
Complete Code
ObservableBootcamp.swift
import SwiftUI
@Observable class ObservableViewModel {
var title: String = "Some title" // Automatically observable — no @Published needed
//@ObservationIgnored var value: String = "Some title" // Opt out of tracking with this annotation
}
struct ObservableBootcamp: View {
// @State creates and owns the ObservableViewModel — it lives as long as this view
@State private var viewModel = ObservableViewModel()
var body: some View {
VStack(spacing: 40) {
Button(viewModel.title) { // This view re-renders only when viewModel.title changes
viewModel.title = "new title!"
}
SomeChildView(viewModel: viewModel) // Pass the model directly — SomeChildView uses @Bindable
SomeThirdView() // No direct injection — receives viewModel via .environment below
}
.environment(viewModel) // Injects viewModel into the environment for all descendant views
}
}
struct SomeChildView: View {
// @Bindable allows creating Binding<T> from properties of an @Observable object
@Bindable var viewModel: ObservableViewModel
var body: some View {
Button(viewModel.title) {
viewModel.title = "asdkjf;alsdjfl;ksadjf!" // Direct mutation — works because @Bindable
}
}
}
struct SomeThirdView: View {
// @Environment reads ObservableViewModel from the environment — no prop drilling needed
@Environment(ObservableViewModel.self) var viewModel
var body: some View {
Button(viewModel.title) {
viewModel.title = "Third view!!!!!" // Reference types are mutated directly — no $ needed
}
}
}
#Preview {
ObservableBootcamp()
}Code Walkthrough
@Observable class ObservableViewModel— The@Observablemacro expands at compile time to synthesize observation infrastructure. Every stored property (title) gets a tracking accessor. ReadingviewModel.titlein a SwiftUI view body automatically registers that view as an observer oftitle.@State private var viewModel = ObservableViewModel()— Using@State(not@StateObject) for an@Observableobject is correct in iOS 17+. SwiftUI knows how to manage the lifetime of an@Observableobject owned by@State. This is a key change from the old@StateObjectpattern..environment(viewModel)— Injects the@Observableobject into SwiftUI's environment. This is the@Observableequivalent of.environmentObject(). Notice no key path is needed — SwiftUI uses the type (ObservableViewModel.self) as the key automatically.@Bindable var viewModel: ObservableViewModel— The@Bindableproperty wrapper (iOS 17+) enables creating bindings from@Observableobject properties. You'd use$viewModel.titleto get aBindingto the string if you needed to pass it to aTextField. Direct mutation (viewModel.title = "...") also works without a binding.@Environment(ObservableViewModel.self) var viewModel— Reads the model from the environment using the type as the key. If no ancestor has injected this type, the app crashes at runtime. This is safe here becauseObservableBootcampalways injects it with.environment(viewModel)beforeSomeThirdViewis rendered.All three views sharing the same object — Tapping any button changes
titleon the single sharedObservableViewModel. All three views that displayviewModel.titlere-render, because they all read that property. This demonstrates how@Observableprovides a single source of truth across the hierarchy.
Common Mistakes
Mistake: Using @StateObject and @ObservedObject with @Observable classes@Observable objects work with @State, @Bindable, and @Environment — not with @StateObject or @ObservedObject. Mixing the old property wrappers with the new @Observable macro compiles but behaves incorrectly (views may not update). Stick to the new wrappers.
Mistake: Forgetting that @Observable requires iOS 17+
The macro and the new Observation framework it relies on are iOS 17 only. If your minimum deployment target is iOS 16 or lower, you must use ObservableObject + @Published + @StateObject / @ObservedObject / @EnvironmentObject instead.
Mistake: Using @Environment without injecting the model first@Environment(ObservableViewModel.self) will crash at runtime with a "No ObservableViewModel in environment" error if no ancestor has called .environment(viewModel). This is the same danger as @EnvironmentObject. Always inject the model before any view that reads it from the environment.
Key Takeaways
@Observableeliminates@Publishedon every property — all stored properties become observable by default, with fine-grained, per-property update tracking- Own the model with
@State, receive it for mutation with@Bindable, and access it from anywhere in the hierarchy with@Environment(_:)after injecting via.environment(_:) - Only views that actually read a changed property re-render — this is more efficient than
ObservableObjectwhich notified all observers on any change
Last updated: June 27, 2026