Remove ObservableObject from your SwiftUI model

Vladyslav Shkodych
7 min readApr 2, 2024

Step by step, SwiftUI becomes more convenient, in iOS development. It is time to talk about switching to @Observable over the ObservableObject.

While ObservableObject has served as a foundational tool for reactive programming, @Observable macro introduces a more streamlined approach, fostering cleaner code, improved performance, and enhanced data flow management.

One important thing: This magic macro is only available for iOS 17.0 and above. Yeah, sorry to those whose app’s minimum supported iOS version is lower than 17.0. It’s the kind of thing that Apple always does to all of us :(

ObservableObject has long been favored for its ability to facilitate data binding and state management in SwiftUI. However, as the complexity of the applications grows, maintaining clarity and efficiency becomes paramount. This is where @Observable emerges as a compelling alternative, offering a more concise syntax and a direct integration with Swift’s property wrappers.

One of the key advantages of @Observable is its seamless integration with model properties, allowing for more granular control over data changes and updates. By annotating specific properties with @Observable, developers can precisely define which parts of the model trigger UI updates, leading to optimized rendering and reduced overhead.

Transitioning from ObservableObject to @Observable involves a relatively straightforward migration process. Let’s look closely at what we have and what we could have. Here is the simple code with the UI display.

First of all, Direct approach: (@State)

import SwiftUI

struct ContentView: View {

@State private var name: String = "Vladyslav Shkodych"
@State private var jobTitle: String = "iOS Developer"
@State private var followerContent: Int = 99

var body: some View {
content
.padding()
}

private var content: some View {
VStack(alignment: .leading) {
Text(name)
.font(.title.bold())
Text(jobTitle)
.foregroundStyle(.secondary)
HStack(spacing: 4.0) {
Text("\(followerContent)")
.monospaced()
.font(.system(.title2, weight: .semibold))
Text("Followers")
}
Button {
followerContent += 1
} label: {
Text("Subscribe")
}
.buttonStyle(.borderedProminent)
.padding(.top, 30.0)
}
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

Here we use the @State property wrapper for our parameters. As everyone knows, changing one of those properties will trigger the SwiftUIs reconstruction mechanism for the view. Here we trigger it by tapping the button, which increases the followerContent value. Super simple, quick, and easy solution.
But what if this View is a part of the big app with its architecture and special code style? For example, we must move all the “logic” part away from the View itself, to some Model or ViewModel, as many of us do.

An old approach: (ObservableObject)

import SwiftUI

struct ContentView: View {

@StateObject var model: ContentViewModel = ContentViewModel(
name: "Vladyslav Shkodych",
jobTitle: "iOS Developer",
followerContent: 99
)

var body: some View {
content
.padding()
}

private var content: some View {
VStack(alignment: .leading) {
Text(model.name)
.font(.title.bold())
Text(model.jobTitle)
.foregroundStyle(.secondary)
HStack(spacing: 4.0) {
Text("\(model.followerContent)")
.monospaced()
.font(.system(.title2, weight: .semibold))
Text("Followers")
}
Button {
model.followerContent += 1
} label: {
Text("Subscribe")
}
.buttonStyle(.borderedProminent)
.padding(.top, 30.0)
}
}
}

final class ContentViewModel: ObservableObject {

@Published var name: String
@Published var jobTitle: String
@Published var followerContent: Int

init(name: String, jobTitle: String, followerContent: Int) {
self.name = name
self.jobTitle = jobTitle
self.followerContent = followerContent
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

Here we use an old but strong solution with an ObservalbeObject protocol.
Quick note, to achieve the same behavior as we have with simple @State we must use the following:

  • ObservalbeObject protocol
  • @Published property wrapper
  • @StateObject / @ObservedObject property wrapper (depends on the context)

For this view, it is an overhead, 100%. But this is just an example. By changing any of the @Published properties values we will trigger the SwiftUI View update.

And here is the thing. SwiftUI will update the whole View by any updating of the Model. This is like: “Oh, I see you changed something in the ObservableObject model, let’s reconstruct the view to be 100% sure that everything is on the right spot!”

For the complex Views that could be the major issue for performance!
But like I said, this is an old approach.

A new way: (@Observable)

import SwiftUI
// import Observation

struct ContentView: View {

var model: ContentViewModel = ContentViewModel(
name: "Vladyslav Shkodych",
jobTitle: "iOS Developer",
followerContent: 99
)

var body: some View {
content
.padding()
}

private var content: some View {
VStack(alignment: .leading) {
Text(model.name)
.font(.title.bold())
Text(model.jobTitle)
.foregroundStyle(.secondary)
HStack(spacing: 4.0) {
Text("\(model.followerContent)")
.monospaced()
.font(.system(.title2, weight: .semibold))
Text("Followers")
}
Button {
model.followerContent += 1
} label: {
Text("Subscribe")
}
.buttonStyle(.borderedProminent)
.padding(.top, 30.0)
}
}
}

@Observable
final class ContentViewModel {

var name: String
var jobTitle: String
var followerContent: Int

init(name: String, jobTitle: String, followerContent: Int) {
self.name = name
self.jobTitle = jobTitle
self.followerContent = followerContent
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

As you can see, a new approach is simpler than an old one. That is still the same code, but there is only one “@” here. @Observable is a macro, so it does some preparations “under the hood” for us. You can look at what is going on with the “Expand Macro” command in your Xcode.

You can see all that observation stuff as @ObservationTracked for properties and conforming to Observation.Observable protocol.

By the way, the @Observable macro is in the Observation library, which is inside the SwiftUI lib, so you don’t need to import it directly.

So, there is no more need for the @StateObject or @ObservedObject, and understanding the difference between them, you don’t need to worry about any @Published wrappers, and definitely, you don’t need to worry about the performance.
Remember, @Observable was created to remove all performance issues that were with the ObservableObject. It works in another way, not like ObservableObject. It will trigger, under the hood, the SwiftUI View updates only for those parts of the view, that are connected to the changed properties. And that is a huge performance boost!

Let's clarify, you don’t need to mark the model with some special wrappers to get the observation, only the usual “var”. SwiftUI already knows that the model used inside the View is Observable, and the View will be updated when necessary.

Of course, all this mechanism is hidden deep inside, and if you need to, you can take a shovel. But remember, you still can drive a car even if you don’t have a deep understanding of how the engine or clutch works.

Some of you could notice that @Sate and @StateObject/@ObservedObject give us a bindable “$” projected value, and you are right, @Observable doesn’t have it, at all. We can’t write something like this:

TextField("Placeholder", text: $model.text)

So how can we be with this?! The answer is @Bindable.

@Bindable

To achieve projected values with @Observable models, add @Bindable for the property like so:

struct ContentView: View {

@Bindable var model: ContentViewModel
...
}

@Bindable doesn’t manage state so be careful and don’t init such models inside! Always set them from outside (same as ObservedObject).

Or even like so:

@ViewBuilder
private var textField: some View {
@Bindable var model = model
TextField("Placeholder", text: $model.text)
}

Yeah, you can make them @Bindable inside computed property or function but don’t forget to add a @ViewBuilder at the top.

And that’s it, you do have the projected values now!

You can also add a @State wrapper for the model property, it’s just an option and not the direct approach. (I suppose you need to do this — never)

struct ContentView: View {

@State var model: ContentViewModel
...
}

@Environment

Now you can complain: “How, in the hell, can I use this in SwiftUI style, with Environments?”. And the answer is so easy, you can do it like so:

@main
struct MagicWorkApp: App {

private var model = ContentViewModel(
name: "Vladyslav Shkodych",
jobTitle: "iOS Developer",
followerContent: 99
)

var body: some Scene {
WindowGroup {
ContentView()
.environment(model)
}
}
}

struct ContentView: View {

@Environment(ContentViewModel.self) private var model

var body: some View {
content
.padding()
}
...
}

And if you want to or need to use @Bindable you can add it inside a computed property or a function, as shown above.

Conclusion

That is it, @Observable has great integration, simple syntax, and a boost for performance. Profit! By embracing this new @Observable, you can improve efficiency, make your code easier to maintain, and enhance performance levels.

So, which style would you prefer: direct, old, or new?
Have fun coding :)

--

--