The @Observable Macro: A New Way to Track Changes to Data in SwiftUI

In this post we will dive into the new @Observable Macro in SwiftUI. Starting with iOS 17 and Xcode 15 this is the new way of making our Models Observable.

In my latest Post we were talking about the power of @State and @Binding property wrappers, its use cases and how they work.

This time, I promised to talk about more advanced property wrappers like @StateObject and @ObservedObject, the thing is that in WWDC23, Apple announced iOS 17, with a new way of making our models Observable with the introduction of @Observable Macro.

In its simplest terms what we’re going to cover here is the ability to propagate changes to our views when our model changes.

As we saw in the latest post with @State & @Binding we can “listen” and act as the @State property changes over the lifetime of a view.

In this case we’re going to leverage the latest new goodies of the SwiftUI Framework: @Observable.

Let’s make a simple enough model to see an example:

class Model {
	var count: Int
} 

Here, we declare a simple class with only a count property.

If we need to use this Data Model in our SwiftUI View we need to add only 2 things:

@Observable
class Model {
	var count: Int = 1
} 

The first thing is the brand new @Observable macro.

The other thing is a default value.

This Macro tell SwiftUI that we care about “listening” to changes that happen inside this class.

In our SwiftUI for instance we need to create our instances like this:

import SwiftUI

struct ContentView: View {
    var model = Model()
    
    var body: some View {
				Text("Observable in Action")
            .font(.title)
        
        Button("Add+") {
            model.count += 1
        }
        .font(.title2)
        
        Text("The count is: \(model.count)")
    }
}

The only thing that we need is to declare a new simple variable and instantiate it, nothing else.

After that, inside the view itself, we can start using it utilizing dot notation to access to the different properties of the class.

If you pay close attention to the above code you will see that I created a Button that adds up a 1 each time the button is pressed, and also a Text view where we can read the value of our count property.

If you run the app, in Preview is just enough, you can see that the button certainly adds 1 to the count property.


Before iOS 17

Before iOS 17 the way of accomplish something like this was more verbose and was not as fast and accurate as it is with this implementation.

You first need to conform your model classes to the ObservableObject Protocol.

Then inside your Model Class you need to specify which properties are going to be @Published so the views can listen to their changes and update itself.

class SomeClass: ObservableObject {
	@Published var name: String
	@Published var date: Date
}

In your views you can opt to use a @StateObject or an @ObservedObejct in order to listen to model changes. Basically the difference between an @StateObject and an @ObservedObject is that @StateObject is thought to be used for the first initialization, and any further view that needs to listen to changes would be to be injected and declared as @ObservedObject.

struct ContentView: View {
	@StateObject var someClass: SomeClass = SomeClass() 

	var body: some View {
		Text("The name is \(someClass.name)")
	}
}

As you can see this may lead to potential problems, which is the initial one and when to use the other.

And after that, in your views you had to reference the Model Data and to do that you can select @StateObject or @ObservableObject (depending on View Hierarchy basically which one) to stay up to date with changes in the class and make the appropriate changes in the view.

If you will use a child view you can start passing the dependency but instead of instantiate it you inject it, like this:

struct ContentView: View {
	@StateObject var someClass: SomeClass = SomeClass() 

	var body: some View {
		Text("The name is \(someClass.name)")

		ChildView(model: someClass)
	}
}

struct ChildView: View {
	@ObservedObject var model: SomeClass

	var body: some View {
		Text("The name is \(model.name)
	}
}

Transforming your current models

Adopting the new @Observable macro in your existing models is pretty easy.

  1. You need to mark your model with the new @Observable macro.
  2. Remove the conformance to @ObservableObject protocol
  3. Remove any @Published property wrapper from your properties.

Take a look, it’s much leaner and clear now:

@Observable
class SomeClass {
	var name: String = ""
	var date: Date = 
}

This way your existing model now is ready to use the full benefits of this new way of model your data in SwiftUI.

Now in your view you have some property wrappers to take advantage of your new @Observable compliant model:

@State

@Environment

@Bindable


Let’s dive into each of these.

@State

When the view need to have it’s own state stored in a model to pass it to a subView, use @State. SwiftUI updates the subView anytime an observable property of the object changes, but, and this is important, only when the subView body know that it has the property to update.

Let’s look at an example:

struct ContentView: View {
	@State private var someClass = SomeClass() 

	var body: some View {
		ChildView(model: someClass)
	}
}

struct ChildView: View {
	var model: SomeClass

	var body: some View {
		Text("The name is \(model.name)")
	}
}

In this example, as you can see, first, is much more simple than the older alternative since you don’t have to think about using @StateObject or @ObservedObject, instead, if you need to hold state you just use @State and then you inject your instance to the subView, using a simple var, that’s it.

Then if in your subView you need to mutate the reference to that object, you will need the current @Binding property wrapper.

But if you need to mutate the state of any of the properties of your model, then, you provide the next property wrapper:


@Bindable

Yes, you guest it, a brother for @Binding.

This time in your subViews you declare @Bindable when you will need to deal with many subViews.

Trust me, when your app start to grow you’ll need this.

Take a look a the following code:

First we declare our model.

@Observable
class Model {
    var name = ""
    var count: Int = 1
    var isActive = false
}

Then we instantiate it in our view with @State

struct ContentView: View {
    @State private var model = Model()
    
    var body: some View {
        VStack(alignment: .center) {
            
            Text("Observable in Action")
                .font(.title)
            
            Text("With name: \(model.name)")
                .font(.title2)
            
            AnotherView(model: model)
        }
    }
}

When you need to deal with many subViews you can start to decouple them with different properties to be managed by different views (and or a different developer).

To do that we get to use the new @Bindable property wrapper.

struct AnotherView: View {
    @Bindable var model: Model
    
    var body: some View {
        AnotherSubView(name: $model.name)
        YetAnotherSubView(count: $model.count)
    }
}

Inside the subViews only we care about the particular properties and we can modifiy them independently using the existing @Binding property wrapper.

struct AnotherSubView: View {
    @Binding var name: String
    
    var body: some View {
        
        TextField("Name", text: $name)
            .padding(20)
    }
}

struct YetAnotherSubView: View {
    @Binding var count: Int
    
    var body: some View {
        Text("The count is \(count)")
            .font(.title3)
        
        HStack {
            Text("Tap here to add to the count")
                .font(.callout)
        
            Rectangle()
                .frame(width: 50, height: 50)
                .cornerRadius(30)
                .onTapGesture {
                    count += 1
                }
        }
    }
}

With the new @Bindable property wrapper we can “distribute” our subViews with the different bound values.

When we first pass the model from our ContentView we don’t need to mark it with the $ symbol, because the subView expects a @Bindable property.

Once we set our @Bindable then we can distribute it to the different subViews that expects common @Binding values. This time we need to send them with it’s proper $ symbol.

This is a neat way of managing multiple views that can interact with our new @Observable model.

Now let’s move on to the next one.


@Environment

Before iOS 17 the way to set an EnvironmentValue in code was using with the .environmentObject view modifier. After that you can read it with @EnvironmentObject in your views.

Now with the Observable Pattern we can set it easily as any other @Observable model.


@Observable
class User {
	var name: String = "Default Name"
}

Let’s say you want to pass this as an Environment value for user to your descendant views. Start in the root of your program, set an @State property and then set the .environment for the view, like this:

@main
struct SwiftUI_DemoApp: App {
    @State private var user = User()
    
		var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(user)
        }
    }
}

Then use it in your views in the following way:

struct ContentView: View {
    @Environment(User.self) private var user

    var body: some View {
        VStack(alignment: .center) {
            Text("User name: \(user.name)")
                .font(.title)
        }
    }
}

And that’s it.

There are a number of predefined EnvironmentValues to take advantage of but those are beyond the scope of this Post.


Let’s summarize

  • Using the new @Observable Macro is easier to use Observation in our App Models.
  • Migrating from the previous way of using @ObservableObject to the new @Observable is as easy as a few corrections.
  • When we use the new @Observable Macro our App can gain a noticeable advantage in performance due to the fact that we are updating only what our view body is using and not entire observed models.
  • If you are dealing with a single view declare your @Observable models with a simple var.
  • If you need to mutate the reference of the object use @Binding.
  • If you need to mutate the properties of a model and make it more hierarchical use @Bindable.
  • If you need to set an environment value use @Environment with .environment view modifier.

Now is your turn, give it a try and remember: Don’t copy and paste! If you allow yourself to be lazy enough to just copy/paste then you are wasting an opportunity to level up, your understanding will be limited; give it a try and type it, you can thank me later. 😉

Thanks for reading! 🤓