Implementing Observation in SwiftUI

Β·

13 min read

Implementing Observation in SwiftUI

Hello, everyone. In this article, you will learn how to implement the Observation Macro in SwiftUI and discover its advantages over the previous approach that used the ObservableObject protocol.

But first, let's recap all the property wrappers and tools to manage the state before observation.

Local State

Let's start with @State. It's suitable for simple data(value) types local to a specific view, such as strings, numbers, or booleans. SwiftUI manages the storage and updates the view automatically when the state changes.

For example, here we have a boolean shouldOpenCart that represents the state of a cart list, false means that the cart is closed and not visible to the user, but once we press the button, the value will change to true, and the modal cart will be presented.

struct ProductList: View { 
    @State private var shouldOpenCart = false // πŸ‘ˆπŸ»
    // more properties...

    var body: some View {
        List {
            // ...
        }
        .toolbar {
             ToolbarItem(placement: .navigationBarTrailing) {
                 Button {
                     shouldOpenCart = true // πŸ‘ˆπŸ»
                 } label: {
                     Text("Go to Cart")
                 }
             }
        }
    }
}

Since shouldOpenCart only makes sense to exist in the ProductList's context, @State fits great for this case.

Now, one of the greatest features of SwiftUI is how easily we can compose views and subviews to make our layout effortless. Another great feature is to provide data from one view layer to another and keep our model in a single source of truth.

For a local scope where we need to pass a property decorated with @State, we can use @Binding. @Binding will create a two-way connection between a property that stores data, and a view that displays and modifies it. It allows for the modification of data owned by a parent view or passed down the view hierarchy.

For example, this .sheet modifier will drive the presentation of CartListView. Once it is dismissed, .sheet will mutate shouldOpenCart back to false:

struct ProductList: View { 
    @State private var shouldOpenCart = false
    // more properties...

    var body: some View {
        List {
            // ...
        }
        .toolbar { ... }
        .sheet(isPresented: $shouldOpenCart) { // πŸ‘ˆπŸ»
            CartListView()
        }
    }
}

Global State

@State and @Binding are powerful tools for managing data across views, designed primarily for local states managed internally by a view. However, what happens when we need a broader scope to manage our app's state?

We have a few other property wrappers that SwiftUI provides for us. The first one is @StateObject. Introduced in iOS 14, @StateObject shares a conceptual resemblance with @State by managing and preserving a property across view renders. However, it specifically handles reference types (classes) that conform to the ObservableObject protocol.

In the example below, RootView serves as a central hub, encapsulating three pivotal stores responsible for managing essential aspects of the app's data model: cart items, products, and the user account. These stores are strategically positioned at the root level to ensure global access throughout the app. The current strategy involves specifically passing these stores to the subviews that require them for operation:

import SwiftUI

struct RootView: View {
    @StateObject private var cartStore = CartStore() // πŸ‘ˆπŸ»
    @StateObject private var productStore = ProductStore()
    @StateObject private var accountStore = AccountStore()

    var body: some View {
        TabView {
            ProductList(
                productStore: productStore, // πŸ‘ˆπŸ»
                cartStore: cartStore // πŸ‘ˆπŸ»
            )
                .tabItem {
                    Image(systemName: "list.bullet")
                    Text("Products")
                }
            ProfileView(
                accountStore: accountStore // πŸ‘ˆπŸ»
            )
                .tabItem {
                    Image(systemName: "person.fill")
                    Text("Profile")
                }
        }
    }
}

As I said, each of the stores above conforms to ObservableObject protocol:

class ProductStore: ObservableObject { // πŸ‘ˆπŸ»
    @Published private var products: [Product] // πŸ‘ˆπŸ»
    @Published var loadingState = LoadingState.notStarted // πŸ‘ˆπŸ»
    private let apiClient: APIClient
    // ... more properties

    init(apiClient: APIClient = .live) {
        self.products = []
        self.apiClient = apiClient
    }
}

You also notice the use of @Published to make SwiftUI react when those properties change their values.

Child views like ProductList will hold their store dependencies using @ObservedObject, which allows a SwiftUI view to react to changes in external model objects, redrawing the view when any observed data changes:

struct ProductList: View {
    @ObservedObject var productStore: ProductStore
    @ObservedObject var cartStore: CartStore

    var body: some View {
        List { ... }
    }
}

The above approach is great if you pass information from a couple of views, but it may become annoying if the view that requires the info is much deeper. All the middle views will have to receive the parameters even if they don't use them, which is not great for many reasons.

In the following image, you can see that View1 will provide the model to View7, but using @StateObject and @ObservedObject, will lead in passing the model through View3 and View5 even if they don't need it at all:

When you have a use case like that, it's better to use Environment Objects.

Environment Objects

Environment objects will avoid creating explicit dependencies for the middle views when you want to pass a model to a deeper level.

In the code example below, we are keeping @StateObject in the parent (root) view, but now, we need to invoke the environmentObject modifier and pass the store objects from there:

import SwiftUI

struct RootView: View {
    @StateObject private var cartStore = CartStore()
    @StateObject private var productStore = ProductStore()
    @StateObject private var accountStore = AccountStore()

    var body: some View {
        TabView {
            ProductList()
                .environmentObject(cartStore) // πŸ‘ˆπŸ»
                .environmentObject(productStore) // πŸ‘ˆπŸ»
                .tabItem {
                    Image(systemName: "list.bullet")
                    Text("Products")
                }
            ProfileView()
                .environmentObject(accountStore) // πŸ‘ˆπŸ»
                .tabItem {
                    Image(systemName: "person.fill")
                    Text("Profile")
                }
        }
    }
}

That's it! Now all child views have access to the environment object at any time. To call an environment object from any view, you have to use @EnvironmentObject.

In this example, AddToCartButton is a child view in a very deep level. Here we are calling cartStore to add a product to the cart the first time, or calling PlusMinusButton to add or subtract quantity:

struct AddToCartButton: View {
    let product: Product
    @EnvironmentObject private var cartStore: CartStore

    var body: some View {
        if cartStore.quantity(for: product) > 0 {
            PlusMinusButton(product: product)
        } else {
            Button {
                cartStore.addToCart(product: product)
            } label: {
                Text("Add to Cart")
            }
        }
    }
}

To avoid making this article longer, I won't talk about Environment Values. They are environment objects too, but with a different configuration.

TL;DR

If you want a TL;DR, this table summarizes all the property wrappers we just talked about:

Source of TruthDependencyScope
Value Type@State@BindingLocal
Reference Type@StateObject@ObservedObject @Environment @EnvironmentObjectGlobal

Up until mid-2023, this was the state of the art for SwiftUI in state management. However, that year marked a significant milestone in the development of Swift and SwiftUI, as Apple unveiled an extraordinary new feature: Macros.

The introduction of Swift Macros opened up a realm of possibilities for streamlining and refining Swift code to an unprecedented level of cleanliness and efficiency. Leveraging this powerful feature, Apple also brought significant enhancements to SwiftUI.

Among these innovations was the introduction of the @Observation macro, a tool designed to simplify the declaration of observable objects within our applications. This development represented a leap forward in making state management in SwiftUI more intuitive and less boilerplate-heavy, facilitating a smoother and more productive development experience.

By the way, you can create your own macro too. If you want to learn more, check out this video

Let's now review Observation and how to migrate your Observable Objects to @Observation Macro!

Migrating to Observation

Remember the TL;DR table that I created earlier? This is a summary of all the property wrappers you need using Observation:

Source of TruthDependency
Observable Type@State@Enviroment@Bindable

Pretty cool isn't it? With Observation, Apple simplified state management drastically for SwiftUI. Let's review each of the property wrappers and make changes in the previous code to start using Observation Macro. Let's refactor the previous stores conforming ObservableObject to adopt the @Observable macro.

Remember that @Observation macro is only available on iOS/iPadOS 17+, macOS 14+, watchOS 10+, tvOS 17+ and... visionOS 1.0+!

Let's use ProductStore as example. To adopt Observable macro you need to:

  1. Remove ObservableObject from the declaration.

  2. Remove @Published from all properties.

  3. Add @Observable macro to the declaration.

// BEFORE:
class ProductStore: ObservableObject { // 1.
    enum LoadingState { ... }
    @Published private var products: [Product] // 2.
    @Published var loadingState = LoadingState.notStarted // 2.
    private let apiClient: APIClient

    init(apiClient: APIClient = .live) { ... }
    func fetchProducts() async { ... }
}
// AFTER:
@Observable // 3.
class ProductStore {
    enum LoadingState { ... }
    private var products: [Product]
    var loadingState = LoadingState.notStarted
    private let apiClient: APIClient

    init(apiClient: APIClient = .live) { ... }
    func fetchProducts() async { ... }
}

That's it! Now ProductStore is fully migrated to Observable macro. That was fast 🀯!

By the way, macros are not "magic", you can right-click on a macro to expand the macro and reveal the actual code generated. This is the code generated to make Observable do its job:

@Observable
class ProductStore {
    enum LoadingState { ... }

    @ObservationTracked // Expanded Macro πŸ‘ˆπŸ»
    private var products: [Product]

    @ObservationTracked // Expanded Macro πŸ‘ˆπŸ»
    var loadingState = LoadingState.notStarted

    private let apiClient: APIClient

    init(apiClient: APIClient = .live) { ... }
    func fetchProducts() async { ... }

    /* Expanded Macro: πŸ‘‡πŸ»
    @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()

    internal nonisolated func access<Member>(
        keyPath: KeyPath<ProductStore , Member>
    ) {
      _$observationRegistrar.access(self, keyPath: keyPath)
    }

    internal nonisolated func withMutation<Member, MutationResult>(
      keyPath: KeyPath<ProductStore , Member>,
      _ mutation: () throws -> MutationResult
    ) rethrows -> MutationResult {
      try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
    }
    */
}
/* Expanded Macro πŸ‘‡πŸ»
extension ProductStore: Observation.Observable {
}
*/

If you've already started with macros and want more advanced topics, check out this playlist from my Youtube Channel "Swift and Tips"!

Follow the same process to migrate CartStore and AccountStore and now our app is ready to apply Observation from the views!

Published is gone, Now What? πŸ€”

Some of you may wonder, "Hey Pitt, if you removed @Published from the properties, how will SwiftUI know which properties to track to update a view?"

The great news is that Observation will infer the views attached to your view's body, and those will be marked as @ObservationTracked internally (as you saw in the expanded code above). Keep reading, because we will talk more about that in just a moment πŸ˜‰.

Let's finally review the property wrappers that support Observation.

State with Observation

@State now works not only for value types but also for all objects conforming to the @Observable Macro (which internally adheres to the Observable Protocol).

In RootView, we can replace @StateObject with @State and still maintain the same functionality, preserving our global state across the app:

//BEFORE:
struct RootView: View {
    @StateObject private var cartStore = CartStore()
    @StateObject private var productStore = ProductStore()
    @StateObject private var accountStore = AccountStore()
}
//AFTER:
struct RootView: View {
    @State private var cartStore = CartStore()
    @State private var productStore = ProductStore()
    @State private var accountStore = AccountStore()
}

Environment with Observation

Environment objects in observation work identically to the older approach, but they are even simpler to set up. To make them work with Observation, let's replace environmentObject with environment modifier:

import SwiftUI
// BEFORE:
struct RootView: View {
    @StateObject private var cartStore = CartStore()
    @StateObject private var productStore = ProductStore()
    @StateObject private var accountStore = AccountStore()

    var body: some View {
        TabView {
            ProductList()
                .environmentObject(cartStore)  // πŸ‘ˆπŸ»
                .environmentObject(productStore) // πŸ‘ˆπŸ»
                .tabItem {
                    Image(systemName: "list.bullet")
                    Text("Products")
                }
            ProfileView()
                .environmentObject(accountStore) // πŸ‘ˆπŸ»
                .tabItem {
                    Image(systemName: "person.fill")
                    Text("Profile")
                }
        }
    }
}
import SwiftUI

// AFTER:
struct RootView: View {
    @State private var cartStore = CartStore()
    @State private var productStore = ProductStore()
    @State private var accountStore = AccountStore()

    var body: some View {
        TabView {
            ProductList()
                .environment(cartStore) // πŸ‘ˆπŸ»
                .environment(productStore) // πŸ‘ˆπŸ»
                .tabItem {
                    Image(systemName: "list.bullet")
                    Text("Products")
                }
            ProfileView()
                .environment(accountStore) // πŸ‘ˆπŸ»
                .tabItem {
                    Image(systemName: "person.fill")
                    Text("Profile")
                }
        }
    }
}

Now your store objects are ready to work with any child view. To call them in the view, call the property attaching @Environment and the object's type between parenthesis:

// BEFORE: 
struct AddToCartButton: View {
    let product: Product
    @EnvironmentObject private var cartStore: CartStore // πŸ‘ˆπŸ»

    var body: some View {
        if cartStore.quantity(for: product) > 0 {
            PlusMinusButton(product: product)
        } else {
            Button {
                cartStore.addToCart(product: product)
            } label: {
                Text("Add to Cart")
            }
        }
    }
}
// AFTER:
struct AddToCartButton: View {
    let product: Product
    @Environment(CartStore.self) private var cartStore // πŸ‘ˆπŸ»

    var body: some View {
        if cartStore.quantity(for: product) > 0 {
            PlusMinusButton(product: product)
        } else {
            Button {
                cartStore.addToCart(product: product)
            } label: {
                Text("Add to Cart")
            }
        }
    }
}

You can create an EnvironmentValue and call them as a key path. For example, the previous example would be @Environment(\.cartStore). Check out more info in this link.

Bindable

Lastly, we have @Bindable, which is similar to @Binding, but it will help you to create bindings for your objects conforming to observation.

In the example below, we are decorating accountStore with @Bindable, and now it can be passed to TextField, which requires a Binding object to mutate the user's first and last name:

struct ProfileView: View {
    @Bindable var accountStore: AccountStore

    var body: some View {
        NavigationView {
            Form {
                Section {
                    TextField($accountStore.user.firstName)
                    TextField($accountStore.user.lastName)
                } header: {
                    Text("Full name")
                }
            }
            .navigationTitle("Profile")
        }
    }
}

It is not just a "sugar syntax"

Everything so far sounds like syntax improvements with Observation, but it turns out that we are also getting an important boost in terms of performance.

With Observation, Swift is capable of inferring which properties are used in the view's body and those will be tracked automatically.

Remember the @ObservationTracked macro created by @Observation macro in ProductStore? That's exactly why those properties were marked because the view consume them. Let's see this example:

@Observable
class ProductStore {
    enum LoadingState { ... }

    // @ObservationTracked
    private var products: [Product]
    private let apiClient: APIClient

    // @ObservationTracked
    var loadingState = LoadingState.notStarted

    // ... more code
}

struct ProductList: View {
    @Environment(ProductStore.self) private var productStore
    @Environment(CartStore.self) private var cartStore

    var body: some View {
        NavigationView {
            Group {
                switch productStore.loadingState { // πŸ‘ˆπŸ» 1
                case .loading, .notStarted:
                    ProgressView()
                        .frame(width: 100, height: 100)
                        .task {
                            await productStore.fetchProducts()
                        }
                case .error(let message):
                    Text(message)
                case .empty:
                    Text("No Data Found")
                case .loaded(let products): // πŸ‘ˆπŸ» 2
                    List(products) { product in
                        ProductCell(
                            product: product
                        )
                        .environment(cartStore)
                    }
                    .refreshable {
                        await productStore.fetchProducts()
                    }
                }
            }
            .navigationTitle("Products")
        }

    }
}

There are two things to highlight from ProductList:

  1. productStore.loadingState is being used to render a view depending on the enum value.

  2. products property is even more interesting because it does not come directly from productStore. Let's see what is fetchProducts method doing:

@MainActor
func fetchProducts() async {
    do {
        loadingState = .loading
        products = try await apiClient.fetchProducts() // πŸ‘ˆπŸ»
        loadingState = if products.isEmpty {
            .empty
        } else {
            .loaded(result: products) // πŸ‘ˆπŸ»
        }
    } catch {
        loadingState = .error(message: error.localizedDescription)
    }
}

Look how we assign the result of apiClient.fetchProducts to products and then, if we have some results to show, we pass the products through .loaded's associated value.

In other words, with Observation, SwiftUI is capable of figuring out what is the source feeding .loaded(result: products). I don't know you, but this is very impressive! 🀯

If you want to learn more about how @Observable Macro is doing improvements in SwiftUI's performance, check out this great article created by Antoine, author of avanderlee.com

Wrap Up

That's it for this article! I would like to know what you think about Observation. Feel free to leave a comment or question down below.

You can check out my full online store demo that implements Observation and MV pattern from this link.

This is my first article created in swiftandtips.com. If the info was useful, don't forget to share it on your social media. That will help me to reach out to more people, thank you! 🫢🏼

Remember, my name is Pitt and this, this is swiftandtips.com! Thanks for reading and have a great day! πŸ‘‹πŸ»

References

  1. https://developer.apple.com/documentation/observation

  2. https://developer.apple.com/documentation/SwiftUI/Migrating-from-the-observable-object-protocol-to-the-observable-macro

  3. https://developer.apple.com/documentation/SwiftUI/Managing-model-data-in-your-app

  4. https://developer.apple.com/documentation/observation/observationignored()

  5. https://developer.apple.com/videos/play/wwdc2023/10149/

  6. https://developer.apple.com/videos/play/wwdc2023/10148/?time=632

  7. https://developer.apple.com/documentation/swiftui/bindable

  8. https://developer.apple.com/documentation/swiftui/environmentvalues/

Β