Skip to content

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 @Observable replaces ObservableObject + @Published with zero boilerplate
  • How to share an observable model with child views using direct injection (@Bindable) versus environment injection (@Environment)
  • Why @Observable only 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:

  1. @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.

  2. @Bindable var viewModel: ViewModel in a child view — receives the model and can create Binding values from its properties using $viewModel.someProperty. Use this when the child needs to write back to the model.

  3. @Environment(ViewModel.self) var viewModel in 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

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

  1. @Observable class ObservableViewModel — The @Observable macro expands at compile time to synthesize observation infrastructure. Every stored property (title) gets a tracking accessor. Reading viewModel.title in a SwiftUI view body automatically registers that view as an observer of title.

  2. @State private var viewModel = ObservableViewModel() — Using @State (not @StateObject) for an @Observable object is correct in iOS 17+. SwiftUI knows how to manage the lifetime of an @Observable object owned by @State. This is a key change from the old @StateObject pattern.

  3. .environment(viewModel) — Injects the @Observable object into SwiftUI's environment. This is the @Observable equivalent of .environmentObject(). Notice no key path is needed — SwiftUI uses the type (ObservableViewModel.self) as the key automatically.

  4. @Bindable var viewModel: ObservableViewModel — The @Bindable property wrapper (iOS 17+) enables creating bindings from @Observable object properties. You'd use $viewModel.title to get a Binding to the string if you needed to pass it to a TextField. Direct mutation (viewModel.title = "...") also works without a binding.

  5. @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 because ObservableBootcamp always injects it with .environment(viewModel) before SomeThirdView is rendered.

  6. All three views sharing the same object — Tapping any button changes title on the single shared ObservableViewModel. All three views that display viewModel.title re-render, because they all read that property. This demonstrates how @Observable provides 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

  • @Observable eliminates @Published on 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 ObservableObject which notified all observers on any change

Last updated: June 27, 2026

Released under the MIT License.