Getting started with @Observable in SwiftUI
Published on: February 6, 2024With iOS 17, we’ve gained a new way to provide observable data to our SwiftUI views. Until iOS 17, we’d use either an ObservableObject
with @StateObject
, @ObservedObject
, or @EnvironmentObject
whenever we had a reference type that we wanted to observe in one of our SwiftUI views. For lots of apps this worked absolutely fine, but these objects have a dependency on the Combine framework (which in my opinion isn’t a big deal), and they made it really hard for developers to limit which properties a view would observe.
In iOS 17, we gained the new @Observable
macro. I wrote about this macro before in this post where I talk about the @Observable
macro as well as @Bindable
which is a new property wrapper in iOS 17.
In this post, we’ll explore the new @Observable
macro, we’ll explore how this macro can be used, and how it compares to the old way of doing things with ObservableObject
.
Note that I won’t distinguish between @StateObject
, @ObservableObject
, and @EnvironmentObject
unless needed. Otherwise, I will write ObservableObject
to refer to the protocol instead.
If you prefer to consume content like this in a video format, you can watch the video for this post below:
Defining a simple @Observable
model
The @Observable
macro can only be applied to classes, here’s what that looks like:
@Observable
class AppSettings {
var hidesTitles = false
var trackHistory = true
var readingListEnabled = true
var colorScheme = ColorScheme.system
}
This AppSettings
class holds on to several properties that can be used to configure several settings on a fictional app. The @Observable
macro inserts a bunch of code when we compile our app. For example, the macro makes our AppSettings
object conform to the Observable
protocol, and it implements several “bookkeeping” properties and functions that enable observing properties on our object.
The details of how this works, and which properties and functions are added are not relevant for now. But if you’d like to see he inserted code, you can right click on the macro in Xcode and choose Expand macro to see the generated code.
We don’t have to add anything other than what we have so far to define our model. Let’s take a look at how we can use an @Observable
in our SwiftUI views.
Using @Observable
in a SwiftUI view
When you’re working with an ObservableObject
in SwiftUI, you have to explicitly opt-in to observing. With @Observable
, this is no longer needed.
Typically, you’ll see an @Observable
used in one of four ways in a view:
struct SampleView: View {
// the view owns this instance
@State var appSettings = AppSettings()
// the view receives this instance
let appSettings: AppSettings
// the view receives this instance and wants to bind to properties
@Bindable var appSettings: AppSettings
// we're grabbing this AppSettings object from the Environment
@Environment(AppSettings.self) var appSettings
var body: some View {
// ...
}
}
Let’s take a closer look at each of these options to understand the implications and use cases for our views.
Initializing an @Observable
as @State
The first way to set up an @Observable
is initializing it as @State
on a view. While this might look and feel logical to you, it’s actually quite interesting that we can (and should) use @State
for our observables.
With ObservableObject
, we need to use a specific property wrapper to tell the view “this object is a source of truth”. This allows SwiftUI to redraw your view when the object updates one of its @Published
properties.
Note that the view won’t care which property changed. Any change to any @Published
property will cause your view body to be re-evaluated (and redrawn) regardless of whether the object update results in a changed view.
On iOS 16 and before, you use @State
for simple data types like Int
or String
, or for value types so that assigning a new value to your @State
property causes your view to redraw.
When you apply @State
to your creation of an @Observable
, you do this due to a key characteristic that @State
has. It’s not its ability to tell a view to redraw. It’s @State
's ability to cache the instance it’s applied to across view redraws.
Consider the following example where we define a view that nests another view. The nested view uses an @Observable
that’s not annotated with @State
.
@Observable
class Counter {
var currentValue: Int = 0
}
struct ContentView: View {
@State var id = UUID()
var body: some View {
VStack {
Button("Change id") {
id = UUID()
}
Text("Current id: \(id)")
ButtonView()
}.padding()
}
}
struct ButtonView: View {
let counter = Counter()
var body: some View {
VStack {
Text("Counter is tapped \(counter.currentValue) times")
Button("Increase") {
counter.currentValue += 1
}
}.padding()
}
}
When you run this code, you’ll find that tapping the Increase button works without any issues. The counter goes up and the view updates.
However, when you tap on Change id the counter resets back to 0.
That’s because once the ContentView
redraws, a new instance of ButtonView
is created which will also create a new Counter
.
If we update the definition of ButtonView
as follows, the problem is fixed:
struct ButtonView: View {
@State var counter = Counter()
var body: some View {
VStack {
Text("Counter is tapped \(counter.currentValue) times")
Button("Increase") {
counter.currentValue += 1
}
}.padding()
}
}
We’ve now wrapped counter
in @State
. Changing the id in this view’s parent now doesn’t reset the counter because @State
caches the counter instance for the duration of this view’s lifecycle. Note that SwiftUI can make several instances of the same view struct even when the view has never actually gone off screen.
There are two points here that are interesting to note:
- We use
@State
to persist our@Observable
instance through the view’s lifecycle - We don’t need a property wrapper to make our view observe an
@Observable
So when exactly do you use @State
on an @Observable
?
There’s a pretty clear answer to that. Only the view that creates the instance of your @Observable
should apply @State
. Every other view shouldn’t.
Defining an @Observable
as a let
property
In the previous section you’ve already seen an example of defining an @Observable
as a let. We only made one mistake when doing so; we owned the instance so we should have used @State
.
However, when we receive our @Observable
from another view, we can safely use a let
instead of @State
:
struct ContentView: View {
@State var id = UUID()
@State var counter = Counter()
var body: some View {
VStack {
Button("Change id") {
id = UUID()
}
Text("Current id: \(id)")
ButtonView(counter: counter)
}.padding()
}
}
struct ButtonView: View {
let counter: Counter
var body: some View {
VStack {
Text("Counter is tapped \(counter.currentValue) times")
Button("Increase") {
counter.currentValue += 1
}
}.padding()
}
}
Notice how we’ve moved the creation of our Counter
up to the ContentView
. The ButtonView
now receives the instance of Counter
as an argument to its initializer. This means that we don’t own this instance, and we don’t need to apply any property wrappers. We can simply use a let
, and SwiftUI will update our view when needed.
However, we’ll quickly run into a limitation with an @Observable
that’s declared as a let
; we can’t bind to it.
Using @Observable
with @Bindable
I will keep this section short, because I have an in-depth post that covers using @Bindable
on an @Observable
.
Consider the following code that tries to bind a TextField
to the query
property on our @Observable
model:
@Observable
class SearchModel {
var query = ""
// ...
}
struct SearchView: View {
let model: SearchModel
var body: some View {
TextField("Search query", text: $model.query)
}
}
The code above doesn’t compile with the following error:
Cannot find '$model' in scope
Because our SearchModel
is a plain let
, we can’t access the $
prefixed version of it that we’re familiar with from ObservableObject
related property wrappers.
Since this view receives the SearchModel
from another view, we can’t apply the @State
property wrapper to our @Observable
. If we did own the SearchModel
instance by creating it, we’d annotate it with @State
and this would enable us to bind to properties of the SearchModel
.
If we want to be able to create bindings to @Observable
models that we don’t own, we can apply the @Bindable
property wrapper instead:
struct SearchView: View {
@Bindable var model: SearchModel
var body: some View {
TextField("Search query", text: $model.query)
}
}
With the @Bindable
property wrapper, we’re able to obtain bindings to properties of the SearchModel
. If you want to learn more about @Bindable
, please refer to my post on this topic.
Using @Observable
with @Environment
Similar to how we can add observable objects to the SwiftUI environment, we can also add our @Observable
objects to the environment. To do this, we can’t use the environmentObject
view modifier, nor do we use the @EnvironmentObject
property wrapper.
Instead, we use the .environment
view modifier which has received some now features in iOS 17 to be able to handle @Observable
models.
The following code adds the SearchModel
you saw earlier to the environment:
struct ContentView: View {
@State var searchModel = SearchModel()
var body: some View {
NestedView()
.environment(searchModel)
}
}
Notice how we’re not passing an environment key along to the .environment
view modifier. That because it works in a similar way to .environmentObject
where we don’t need to pass a specific key. Instead, SwiftUI will enforce that there’s only ever one instance of SearchModel
in our view hierarchy which makes environment keys obsolete.
To extract an @Observable
from the environment, we write the following:
struct NestedView: View {
@Environment(SearchModel.self) var searchModel
}
By writing our code like this, SwiftUI knows which type of object to look for in the environment and we’ll be handed our instance from there.
If SwiftUI can’t find an instance of SearchModel
, our app will crash. This is the same behavior that you might be aware of for @EnvironmentObject
.
Binding to an observable from the environment
Since you can't bind to an object in the environment, you need to obtain an @Bindable
for the observable that you've read from the environment. Imagine that in the NestedView
from before you wanted to pass a binding to the searchModel
's query
property to another view. You'd have to create your @Bindable
inside of the view body like this:
struct NestedView: View {
@Environment(SearchModel.self) var searchModel
var body: some View {
@Bindable var bindableSearchModel = searchModel
OtherView(query: $bindableSearchModel.query)
}
}
Benefits and downside of Observable
Overall, @Observable
is an extremely useful macro that works amazingly with your SwiftUI view.
It’s key feature for me would be how SwiftUI can subscribe to changes on only the properties of an @Observable
that have actually changed.
The Swift team has added a couple of special features to @Observable
that are available to SwiftUI which allow SwiftUI a more powerful way to observe changes than the default withObservationTracking
that you and I have access to. I’ll talk about that more in a bit.
What’s important to understand is that @Observable
allows users of an Observable
to only be notified when a property that was accessed within something called withObservationTracking
was changed.
The withObservationTracking
method on Observable
takes a closure that will allow automatic tracking of properties that got accessed within the closure it receives. This is super useful because it allows us to have much more granular view redraw behavior than before.
However, this observation tracking mechanism isn’t perfect and it comes with downsides.
One of the key downsides for me is that @Observable
does not make it easy to track individual properties on your models over time. Whenever you access properties inside of a withObservationTracking
call, you are informed about the very next change only. Any changes after your initial callback will require a new call to withObservationTracking
.
Also, this means that you can’t easily subscribe to a specific property like you can with @Published
, then transform your received data with Combine operators like debounce
, and then update another property with a result.
It’s not impossible with @Observable
, but it won’t be trivial either. At this point it’s pretty clear that @Observable
was designed to work well with SwiftUI and everything else is a bit of an afterthought.
In Summary
In this post, you’ve learned about the new @Observable
macro that Apple ships alongside iOS 17. You’ve seen some examples of how this new macro can be used, and you’ve seen how it can help your app perform much better by not tracking literally every property on your model that you might ever be interested in.
We’ve also explored downsides. You’ve learned about withObservationTracking
, and the lack of bunch of Combine-linke features.
What do you think about @Observable
? Did you jump in to use it straight away? Or are you still holding off? I’d love if you shared your thoughts on X or Threads.