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 Truth | Dependency | Scope | |
Value Type | @State | @Binding | Local |
Reference Type | @StateObject | @ObservedObject @Environment @EnvironmentObject | Global |
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 Truth | Dependency | |
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:
Remove
ObservableObject
from the declaration.Remove
@Published
from all properties.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
:
productStore.loadingState
is being used to render a view depending on the enum value.products
property is even more interesting because it does not come directly fromproductStore
. Let's see what isfetchProducts
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! ππ»