Swift and SwiftUI tutorials for Swift Developers

@Observable in SwiftUI for iOS, macOS and watchOS

Development in the Apple ecosystem is in constant evolution. If you’ve been working with SwiftUI for a while, you’ve surely dealt with the “alphabet soup” of state management: @StateObject@ObservedObject@EnvironmentObject, and the ever-present @Published. While functional, these property wrappers came with a cognitive load and performance overhead that often complicated our app architecture.

With the arrival of Swift 5.9 (iOS 17, macOS 14, watchOS 10), Apple introduced a massive paradigm shift: the Observation framework and the @Observable macro.

This article is a comprehensive technical guide designed to take you from zero to expert in using @Observable. We will explain not just how to use it, but why it radically changes the way we develop for iOS, macOS, and watchOS.


Part 1: What is @Observable and Why Do We Need It?

To understand the future, we must briefly look at the past. Before Swift 5.9, SwiftUI relied heavily on the Combineframework for reactive data management.

The “Old World” Problem (ObservableObject)

When using ObservableObject, we had to explicitly mark every property we wanted the view to “watch” with @Published.

The fundamental problem was granularity. If you had an object with 10 properties marked as @Published, and a view only used one of them, the view would still be invalidated and redrawn even if one of the other 9 properties (which the view wasn’t even displaying) changed. This generated unnecessary redraws and performance bottlenecks in complex applications.

The Solution: The @Observable Macro

@Observable is not a traditional property wrapper; it is a Macro. Macros in Swift transform your code at compile time.

By adding @Observable before a class, the macro automatically rewrites that class to conform to the Observable protocol. The revolutionary aspect here is precise dependency tracking.

Key Advantages:

  1. Clean Syntax: Goodbye @Published. Properties are observed by default.
  2. Superior Performance: Views only update if a property that the view specifically read in its body changes.
  3. Fewer Property Wrappers: It drastically simplifies dependency injection.

Part 2: Setting Up the Environment in Xcode

Before writing code, ensure you have the right tools. @Observable requires:

  • Xcode: Version 15.0 or higher.
  • Target: iOS 17+, macOS 14+, watchOS 10+, tvOS 17+, or visionOS.

If you are maintaining an app that must support iOS 16 or lower, you won’t be able to fully migrate to this system yet (though you can use conditional code).


Part 3: Practical Tutorial – Creating a Data Model

We are going to build a “User Profile” management system that works across iPhone, Mac, and Apple Watch.

Step 1: Defining the Model

In the old model, we would do this:

// OLD METHOD (Conceptually deprecated)
class UserProfileOld: ObservableObject {
    @Published var name: String = "Dev"
    @Published var isPremium: Bool = false
    var lastLogin: Date = Date() // Does not notify changes
}

With @Observable, the code looks like this:

import Observation
import Foundation

@Observable
class UserProfile {
    var name: String = "Dev"
    var isPremium: Bool = false
    var lastLogin: Date = Date()
    
    // Computed properties are also automatically observed
    var greeting: String {
        return "Hello, \(name)"
    }
    
    // We can exclude properties from observation if necessary
    @ObservationIgnored var analyticsId: String = "XYZ-123"
    
    init(name: String, isPremium: Bool) {
        self.name = name
        self.isPremium = isPremium
    }
}

Technical Note: When compiling, the @Observable macro injects code implementing the access(keyPath:) and withMutation(keyPath:) methods. This connects the properties to the SwiftUI dependency graph invisibly to you.


Part 4: Injection and Usage in Views (iOS/macOS/watchOS)

This is where the magic happens. The way we inject data changes subtly but with great impact.

1. The “Source of Truth” (@State)

Previously, @State was only for value types (structs, ints, bools). For classes, we used @StateObject. Now, @State can manage the lifecycle of @Observable objects.

import SwiftUI

struct ContentView: View {
    // We no longer need @StateObject. @State is sufficient.
    @State private var user = UserProfile(name: "Ana", isPremium: true)
    
    var body: some View {
        VStack {
            // SwiftUI detects that we read 'user.name'.
            // If 'user.isPremium' changes, this view will NOT redraw. Magic!
            Text(user.greeting)
                .font(.largeTitle)
            
            EditProfileView(user: user)
        }
        .padding()
    }
}

2. Passing Data to Children (Binding vs Let)

If you only need to read the object in a child view, pass it as a normal property (let or var). You do not need @ObservedObject.

struct ReadOnlyView: View {
    // Simply declare the type. No wrappers.
    let user: UserProfile 
    
    var body: some View {
        Text("Status: \(user.isPremium ? "Premium" : "Basic")")
    }
}

3. Creating Bindings (@Bindable)

This is the point that confuses developers the most at first. If we need to create a binding (use the $ symbol) for controls like TextFieldToggle, or Stepper, we cannot use the object as is. We need the @Bindable wrapper.

@Bindable creates lightweight bindings to the properties of the observable object.

struct EditProfileView: View {
    // We use @Bindable to access the $bindings
    @Bindable var user: UserProfile
    
    var body: some View {
        Form {
            Section("Edit Information") {
                // Here we need the $, which is why we used @Bindable above
                TextField("Name", text: $user.name)
                
                Toggle("Premium Subscription", isOn: $user.isPremium)
            }
        }
    }
}

If you tried to use var user: UserProfile without @Bindable, Xcode would give you an error when trying to access $user.name.


Part 5: Environment Management

The dependency injection pattern has also been simplified. EnvironmentObject (which relied on dynamic types and caused crashes if you forgot to inject it) is replaced by the safer usage of .environment.

Injecting at the Root (App)

@main
struct MyApp: App {
    @State private var user = UserProfile(name: "Carlos", isPremium: false)

    var body: some Scene {
        WindowGroup {
            ContentView()
                // Inject the object into the environment
                .environment(user)
        }
    }
}

Reading in the View

To read it, we use the @Environment wrapper, not EnvironmentObject.

struct SettingsView: View {
    // Retrieve the object based on its TYPE
    @Environment(UserProfile.self) var user
    
    var body: some View {
        if user.isPremium {
            Text("Advanced Settings")
        } else {
            Text("Upgrade to Premium to see more")
        }
    }
}

This change unifies how we pass simple values (like ColorScheme) and our complex data objects.


Part 6: Multiplatform Adaptability

One of SwiftUI’s great promises is “Learn once, apply anywhere.” The data code we wrote above is 100% identical for iOS, macOS, and watchOS. The difference lies in how we render the UI.

Example for watchOS

On an Apple Watch, space is limited. We might have a specific view:

// WatchOS Specific View
struct WatchUserProfileView: View {
    @State private var user = UserProfile(name: "Ana", isPremium: true)
    
    var body: some View {
        NavigationStack {
            List {
                // The Toggle adapts visually to the watchOS style
                Toggle(isOn: $user.isPremium) {
                    Label("Premium", systemImage: "star.fill")
                }
            }
            .navigationTitle(user.name)
        }
    }
}

Example for macOS

On macOS, we might want a separate settings window:

// macOS Settings View
struct MacSettingsView: View {
    @Environment(UserProfile.self) var user
    
    var body: some View {
        TabView {
            Form {
                TextField("Name", text: Bindable(user).name)
                // Note: Sometimes we can use Bindable() inline if we don't want the global wrapper
            }
            .tabItem { Label("General", systemImage: "gear") }
        }
        .scenePadding()
    }
}

Pro Tip: Notice the use of Bindable(user).name in the macOS example. If you don’t want to decorate the view property with @Bindable, you can create a bindable “on the fly” inside the body.


Part 7: Advanced Cases and Migration

Arrays and Collections

One of ObservableObject‘s weak points was observing changes inside arrays of objects. With @Observable, management is smoother but requires attention.

If you have an array of @Observable models: var users: [UserProfile]

SwiftUI will detect:

  1. If you add or remove elements from the array.
  2. If you change a property of one of the elements (provided the view is rendering that specific element).

Progressive Migration

You don’t need to rewrite your entire app in one day. You can mix ObservableObject and @Observable.

  • New views can use the new system.
  • Old views can continue using @StateObject.

However, do not mix both in the same class. A class should not be @Observable and ObservableObject at the same time.

Common Errors (Gotchas)

  1. Structs vs Classes: @Observable is designed for classes (reference types). If you use structs, continue using simple @State.
  2. Retain Cycles: Just like before, be careful with closures inside your observable classes. Use [weak self].
  3. Views Not Updating: If a view doesn’t update, verify that you are actually reading the property in the body. If you only read the property in a function that is called (but whose result doesn’t change the view structure), the observation system might not register the dependency.

Summary Table: Before and After

ConceptBefore (Legacy SwiftUI)Now (Modern SwiftUI)
Definitionclass Model: ObservableObject@Observable class Model
Publication@Published var datavar data (Automatic)
Creation (Owner)@StateObject var model = Model()@State var model = Model()
Consumption (Read)@ObservedObject var modellet model: Model
Consumption (Binding)@ObservedObject / @Binding@Bindable var model
Environment@EnvironmentObject var model@Environment(Model.self) var model

Conclusion

The introduction of @Observable in Swift 5.9 is more than just syntactic sugar; it is a fundamental reconstruction of how data flows in our applications. By removing the explicit dependency on Combine for the view layer and leveraging the power of Macros, Apple has delivered a tool that is both easier to teach beginners and much more powerful for experts concerned with performance.

For developers working in the Apple ecosystem, adopting @Observable is not optional in the long run. It is the standard that will define SwiftUI development for years to come. It reduces boilerplate code, eliminates common view invalidation bugs, and makes our applications feel faster and smoother.

Whether you are building the next big iOS app, a complex utility for macOS, or a lightweight experience for watchOS, @Observable is your new best friend.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Best SwiftUI frameworks

Next Article

@Observable vs @Published in SwiftUI

Related Posts