Revolutionizing SwiftUI – Goodbye Combine, Hello Observation!

The introduction of iOS 17 brought forth a groundbreaking shift, and it goes by the name of Observation. Step into the future and meet the new framework that allows us to build powerful SwiftUI apps, leaving Combine completely out of the equation. 

SwiftUI is an amazing UI framework that simplifies and facilitates forming even the most complex and demanding user interfaces imaginable. Used together with Combine, it truly provides a unique app-building experience, from constructing the app’s user interface to handling complex business logic that modern applications now accommodate. It is no surprise the interest in these frameworks is rapidly increasing.

However, what would you say if I told you that we no longer need Combine to build a SwiftUI app? It seems rather impossible, but with the introduction of the Observation framework in iOS 17, what used to be impossible has now become a reality.

Just your typical SwiftUI app-building

Before we jump into the world of Observation, let’s kick things off with an example of how we’ve been building SwiftUI applications so far. Let’s create a simple app containing just a text element and a button. We’ll throw in a counter with an initial value of 0 that increases with each button click and print out that value inside our text element. 

Piece of cake, right? To make it slightly more demanding, let’s use MVVM as our architectural pattern. This means we should put the counter logic inside a view model, keeping the business logic outside the view.

	import SwiftUI

struct ContentView: View {
       @StateObject var viewModel = ViewModel()

       var body: some View {
              VStack {
                     Text(viewModel.count.description)
                     Button(“Next”) {
                           viewModel.increaseCount()
                     }
              }
       }
}
	import Combine

class ViewModel: ObservableObject {
       @Published private(set) var count = 0

       func increaseCount() {
              count = count + 1
       }
}

By using the Combine framework inside our view models, we can mark the variables as @Published, allowing view models to communicate the changes back to our views and make them re-render their user interfaces whenever a change occurs. We also need to make the whole class conform to the ObservableObject protocol, which synthesizes an objectWillChange publisher that emits the changed value before any of its @Published properties change.

Of course, SwiftUI uses the principle of mutual communication, so we need to mark the initialized view model variable as a @StateObject inside our view. Without this property wrapper, SwiftUI will not update the view whenever a new value is stored inside the view model’s count variable. Another reason is that SwiftUI views are structs, and we all know we cannot store mutable properties inside structs.

Finally, by building and running our code, we can see everything works as expected. Upon button press, the text element is updated, and we see an increased value on our screens each time. Great!

Now for the fun part.

Combine, get ready for a disappearing act

With the release of iOS 17, Apple introduced us to the Observation framework. As the official documentation states:

Observation provides a robust, type-safe, and performant implementation of the observer design pattern in Swift. This pattern allows an observable object to maintain a list of observers and notify them of specific or general state changes.

The Observation framework provides the following capabilities:

  • Marking a type as observable
  • Tracking changes within an instance of an observable type
  • Observing and utilizing those changes elsewhere, such as in an app’s user interface

This practically means that all we need to do is attach the Observable() macro to our view model and remove every part of code that has anything to do with Combine. This includes the @Published property wrapper as well as conforming to the ObservableObject protocol. Removing the protocol conformance, we cannot have @StateObject in our view anymore, so we’ll just make it a simple @State. Let’s see this in action.

	import Observation

@Observable
class ViewModel {
      private(set) var count = 0

       func increaseCount() {
             count = count + 1
       }
}
	import SwiftUI

struct ContentView: View {
       @State var viewModel = ViewModel()

       var body: some View {
              VStack {
                     Text(viewModel.count.description)
                     Button(“Next”) {
                           viewModel.increaseCount()
                     }
              }
       }
}

With just two lines of code inside our view model, one importing the Observation framework and the other one adding the @Observable macro, we got rid of Combine completely and our app works just as expected. 

The nice thing about macros is that they add code for us and can be expanded by right-clicking and choosing the Expand Macro option in XCode. Let’s do exactly that and see what the macro added for us.

	import Observation

@Observable
class ViewModel {
       @ObservationTracked
       private(set) var count = 0
       @ObservationIgnored 
       private let _$observationRegistrar = ObservationRegistrar()

       internal nonisolated func access<Member>(
              keyPath: KeyPath<ViewModel, Member>
       ) {
              _$observationRegistrar.access(self, keyPath: keyPath)
       }

       internal nonisolated func withMutation<Member, T>(
              keyPath: KeyPath<ViewModel, Member>,
              _ mutation: () throws -> T
       ) rethrows -> T {
              try _$observationRegistrar.withMutation(
                    of: self,
                    keyPath: keyPath,
                    mutation
              )
       }

       @ObservationIgnored 
       private var _count = 0
}

extension ViewModel : Observable {}

Going from the bottom up, we can see that it adds:

  • conformance to the Observable protocol,
  • the observation registrar used inside methods access and withMutation,
  • one @ObservationTracked variable and one @ObservationIgnored variable.

The most important things to notice are the ObservationRegistrar variable and the two methods: access and withMutation. What the registrar actually does is, whenever someone either looks or changes the @ObservationTracked properties, it gets notified. It can, therefore, communicate these changes directly to the SwiftUI views that are watching. 

By inspecting the access method, we can see that each time someone tries to access one of the properties, the attempt gets stored inside the registrar. The same thing happens if someone tries to mutate one of the properties – the mutation is registered inside the very same registrar using the withMutation method.

This incredible mechanism provides us with the tools we never had at our disposal before. Not only does it save a lot of time, but it also makes our code clearer and more perceptible. Nonetheless, we’re not done yet.

Embracing MVVM to the fullest – three mind-blowing insights

Up to this point, we’ve used our view model for handling the business logic of our app. However, view models are only meant to be mediators between views and the business logic. They should not contain the domain code of the application. In our example, the counter and the method that increases the count do not belong inside the view model. What we need is a model.

Nothing too difficult, we just create a class called Model and place the counter and the increase count method inside it. Then we delete the model code from our view model and initialize the newly created model. The view model’s increase count method calls the model’s method, and the model’s count updates correctly. Let’s take a look.

	import Observation

@Observable
class ViewModel {
       var model = Model()

       func increaseCount() { 
              model.increaseCount() 
       }
}
	class Model {
       private(set) var count = 0

       func increaseCount() {
              count = count + 1
       }
}

Even though everything looks fine, there’s a critical problem in our code that is breaking our app’s core functionality. The view model is @Observable, however, our model is not. Therefore, the model inside the view model never changes and the observation is not triggered. 

As a result, the Text element in the SwiftUI view is no longer attached to the @Observable count property, which we moved to our model, and cannot re-render whenever a change is introduced. Reasonably enough, the fix is making our model @Observable as well. And finally, everything updates fine.

	import Observation

@Observable
class Model {
       private(set) var count = 0

       func increaseCount() {
              count = count + 1
       }
}

To recap, we have a SwiftUI view that is holding onto the view model, and our view model is holding onto the model. Tapping the button, we go all the way to the model and update the count. The count’s observable then updates the SwiftUI view. The MVVM principle is thoroughly utilized and the further separation of our code is fully adopted.

That being said, there are just three more things that, I believe, might blow your mind:

1

If the model is observable, then the view model does not need to be observable.

	class ViewModel {
       var model = Model()

       func increaseCount() { 
              model.increaseCount() 
       }
}

2

It works even if the model is a let.

	class ViewModel {
       let model = Model()

       func increaseCount() { 
              model.increaseCount() 
       }
}

3

It works even if we make the view model a struct.

	struct ViewModel {
       let model = Model()

       func increaseCount() { 
              model.increaseCount() 
       }
}

How cool is that? But, I know what you’re thinking. Surely that @State property wrapper from the ContentView’s initialization of view model has something to do with why this is still working. Well, not quite. In fact, let’s remove it. Let’s even initialize the view model from outside of the SwiftUI view. What we’re left with, in the end, is this:

	import SwiftUI

struct ContentView: View {
       let viewModel: ViewModel

       var body: some View {
              VStack {
                     Text(viewModel.model.count.description)
                     Button(“Next”) {
                            viewModel.increaseCount()
                     }
              }
       }
}
	struct ViewModel {
       let  model = Model()

       func increaseCount() { 
               model.increaseCount() 
       }
}
	import Observation

@Observable
class Model {
       private(set) var count = 0

       func increaseCount() {
              count = count + 1
       }
}

We have a SwiftUI view with a view model that never changes. We also have a view model with a model that never changes. And we have a model that is observable. We’ve taken the state handling from the presentation layer of our app and added it to the domain layer. Now the domain layer is the one handling the states. And it is all connected through the global registry with SwiftUI. Now that’s magic!

Encapsulation provides a little bit of extra

There is only one more thing we need to take care of to make our code feel like it was written by a true pro – assembling the principle of encapsulation. We do that with computed properties, however, the big question is how observable properties act when wrapped inside a computed property. Let’s start with adjusting the view model.

	struct ViewModel {
       private let  model = Model()

       var annotatedCount: String {
              "The count is \(model.count)."
       }

       func increaseCount() { 
               model.increaseCount() 
       }
}

We created a computed property called annotatedCount. This property returns the model’s count value. Since the model’s count property is observable, this computed property becomes observable as well. This allows us to privatize view model properties, and as a result, the view is completely ignorant of the model’s existence.

Going above and beyond with Observation

On a final note, I strongly believe that Observation will be the future of building SwiftUI apps. It is a remarkable, new framework that introduced us to practices we never got to experience before. 

There are so many possibilities – for example, imagine taking Observation and moving it inside a persisted model using SwiftData, which uses Observable. Now, that would really be astonishing. But until that happens, take this new framework and start building those powerful SwiftUI apps. And most importantly, have fun doing it.