Comparing @Observable to ObservableObjects

Published on: February 6, 2024

With 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:

  1. We use @State to persist our @Observable instance through the view’s lifecycle
  2. 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.

Categories

Swift SwiftUI

Subscribe to my newsletter