Observable Architecture: Sneak Peek

Episode #259 • Nov 27, 2023 • Free Episode

We’re about to completely revolutionize the Composable Architecture with Swift’s new Observation framework! But first, a sneak peek: we’ll take the public beta (available today!) for a spin to see how the concept of a “view store” completely vanishes when using the new tools.

Oh, and did we mention that the new observation tools in the library have been backported all the way back to iOS 13? This means you can use the tools immediately. 🤯

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in

Introduction

Stephen: Today we begin a series of episodes that will completely revolutionize our popular library, the Composable Architecture. Now we’ve said that before.

  • We first said it when we introduced async/await to the library. That revolutionized how effects were treated in the library.

Brandon:

  • Then we said it again when we introduced the Reducer protocol. That revolutionized how features were composed together and how dependencies were used.

Stephen:

  • And then we said it again when we introduced the navigation tools to the library. That revolutionized how to concisely model your feature’s domains.

Well, we were telling the truth all of those times, but this time it is different. This is an even bigger revolution.

Brandon: A few weeks ago we concluded a series of episodes that dove deep into the new Observation framework released with Swift 5.9. That framework is a part of the open source Swift project, and it aims to be a very general purpose tool for observing how the data inside a class changes over time.

It is so powerful that it completely transforms the way one builds features in SwiftUI. Prior to the Observation framework you had to be intimately familiar with a whole zoo of property wrappers and types in order to meticulously annotate your feature’s state to explicitly tell SwiftUI what state should be observed, and you just had to hope you got it right.

Stephen:: But now with the Observation framework you get to forget about almost all of that complexity, and just build your features in the most naive way, with basically zero adornments, and it just works. And it works in the most efficient way possible, where only the data accessed in the view is observed by the view.

That was pretty revolutionary for SwiftUI, but we think it can be just as revolutionary for the Composable Architecture. The Observation framework allows us to get rid of many concepts that were needed prior to the framework, most importantly the ViewStore concept, but also even things like IfLetStore, ForEachStore, SwitchStore, and a whole plethora of view modifiers for showing sheets, fullscreen covers, popovers, and more.

Brandon: And further, we were able to layer on these tools without breaking any of the old tools. This means when we finally release this update to the library, it will be a non-breaking minor release of the library. And if you are able to make use of Swift 5.9’s observation tools, then you will also be able to make immediately make use of the Composable Architecture’s observation tools.

Stephen: But then we took things further. We back-ported all of the observation tools from the open source Swift project directly to the Composable Architecture. This means you can start making use of observation tools today. Right now. Even if you are still supporting iOS 13! We are not kidding. You will be able to instantly make use of everything we discuss in this series.

Brandon: So, this all sounds maybe too good to be true, but we promise it is true.

And in unison with the beginning of this series we are also releasing a beta of these new observation tools. There is a branch that you can point your projects to today to make sure that your application still compiles, tests pass, and everything runs as you expect. And if you have the appetite for it, you can even try updating some of your features to use the new observation tools and see how things go! Do note that we do not recommend you depend on this branch for the long term as there may be a lot of churn and occasional breakage in that branch as we fix things during the beta period.

Stephen: So, let’s get into it.

We are actually going to start with a quick run-through of what the final tools look like. This is just to give everyone a taste of the exciting things to come. And then we will start to build all new observation tools directly into the library, and see how time and time again we get to remove concepts from the library and simplify features built with the library.

So, let’s get started.

The observable architecture

I’ve got a fresh project already open, and all I am going to do is try to import the Composable Architecture:

import ComposableArchitecture

And Xcode will helpfully suggest adding this dependency to our project, which we will do. But rather than pinning to the latest release, we will pin to the new beta branch we are releasing today. It is called observation-beta.

And then before we can build we must give Xcode permission to use the macros from the Composable Architecture:

“ComposableArchitectureMacros” must be enabled before it can be used.

Enable it now?

Macros with malicious code can harm your Mac or compromise your privacy. Be sure you trust the source of macros before you enable them.

The Composable Architecture now ships its own macros in order to aid in observation. This shouldn’t be too surprising because as we discussed in our previous episodes covering the @Observable macro, that macro only works for classes, and applications built with our library prefer to model their domains with structs. So, we had no choice but to ship new macros that allow observation to work with structs.

But, with the library now imported, let’s create a very basic feature!

We will start by defining a new type to house the feature, and we will annotate it with the @Reducer macro, which was released just two weeks ago:

@Reducer
struct CounterFeature {
}

If this is your first time seeing the @Reducer macro, then don’t worry. It’s not doing a ton right now. It just conforms the CounterFeature to the Reducer protocol, and well as a few basic linting checks inside the reducer type. In the future it will do more, but we don’t have to worry about any of that right now.

Reducers have a few requirements, the first of which is the state that the feature needs to do its job. In this case we just need access to a count integer:

struct State {
  var count = 0
}

And it’s typically at this point you would preemptively go ahead and make this struct Equatable, because if you are familiar with the Composable Architecture then you know that down in the view you will need to observe this state, and in order to minimize view re-computations we need to de-dupe state changes.

Well, that is exactly the kind of thing that should be massively improved with Swift 5.9’s new observation tools. We should stop thinking in terms of skipping view computations based on state literally changing, and instead be thinking in terms of the view tracking which pieces of state were accessed and re-computing only when those properties are mutated.

That would mean we don’t even need to make this Equatable right now, and should make our views more efficient by not needing to compute equality of data types all over the place. But of course eventually you probably will want this struct to be Equatable for tests, but we won’t worry about that right now.

The next requirement is the type that represents all the actions the user can perform in the view. Right now we will just model decrement and increment actions:

enum Action {
  case decrementButtonTapped
  case incrementButtonTapped
}

And the last requirement is the reducer to implements the logic for the feature. This feature is quite simple, so we can just do:

var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .decrementButtonTapped:
      state.count -= 1
      return .none
    case .incrementButtonTapped:
      state.count += 1
      return .none
    }
  }
}

But in reality features are usually quite a bit more complicated, involving the composition of many features and the execution of effects. But we aren’t going to worry about any of that right now.

We now have a very basic Composable Architecture feature implemented, and do so in the fewest possible moving parts. We’ve got the state, the actions, and then the reducer to glue the two together.

Ideally we could take this feature, and plug it into the view in the most naive way possible. For one, I would hope I could hold onto a store directly in the view like so:

struct ContentView: View {
  let store: StoreOf<CounterFeature>
}

The Store is the runtime that powers a view from a Composable Architecture feature. It is responsible for processing actions sent from the view, mutating state, notifying views when state has changed, executing effects, and feeding effect output back into the system.

And if you are familiar with the Composable Architecture, then you will know that there is a sibling concept next to the Store known as a ViewStore. Historically it was necessary in order to actually observe changes inside the store and communicate to SwiftUI that the view needed to be invalidated and rendered again.

We aren’t going to go into all the details, but the crux of it is that you would have to wrap your entire view in a helper view called WithViewStore, and you had to explicitly describe what state to observe, and then you would get back a viewStore object that you could read state from:

var body: some View {
  WithViewStore(
    self.store, observe: { $0 }
  ) { viewStore in 
    Form {
      Text(viewStore.count.description)
    }
  }
}

But there are tons of problems with this:

  • First of all it requires your state to be Equatable, and we haven’t done that yet. Ideally we wouldn’t need to think about that until we were ready to write some tests.
  • Second of all this makes it far to easy to observe all state in your features, which is usually not what you want to do.
  • And third of all, this code is just a pain to maintain. We are incurring an extra indentation level, we have an extra concept to understand, and this escaping closure does put strain on the Swift compiler.

So, let’s forget for a moment that we know about view stores and why they are required to exist. Let’s just be very naive and try using the store directly in the body of the view. Say, showing the current count in a Form:

var body: some View {
  Form {
    Text(self.store.count.description)
  }
}

Well, we are already met with a compilation error:

🛑 Referencing subscript ‘subscript(dynamicMember:)’ on ‘Store’ requires that ‘CounterFeature.State’ conform to ‘ObservableState’

And this is because previously it was not a good idea to reach directly into the store to access the state of your feature. Doing so allows you to display data in your view that will not automatically update when the state changes. That creates buggy and glitchy views, and is one of the big problems that SwiftUI aimed to solve from its inception.

And the error message is even giving us a hint at how we can fix the problem. It seems we need our state to conform to something called the ObservableState protocol. This is a brand new protocol that ships with the Composable Architecture, and one should think of it as the analogous concept of Swift’s Observable protocol, except it is fine tuned to work with structs and enums in our library.

And the way you get your struct to conform to that protocol is via the @ObservableState macro that comes with the library. As we’ve seen before, Swift 5.9’s @Observable macro does not work on structs:

@Observable
struct State {
  var count = 0
}

That causes the following error:

🛑 ‘@Observable’ cannot be applied to struct type ‘State’

And so that is a non-starter.

But our library comes with a macro that is very similar to Swift’s, but it does work on structs:

@ObservableState
struct State {
  var count = 0
}

Not only does this compile, but even the view down below is compiling. Once you use the @ObservableState macro on your feature’s state, you instantly get unfettered access to state in your stores:

Text(self.store.count.description)

This is now completely fine to do because secretly under the hood the struct is now tracking what fields are accessed and what fields are mutated, and that is communicated automatically back to the view to let it know when to re-render.

So, we do have some state showing in the view, but no way to actually change the count. Let’s add some buttons that send actions into the store:

var body: some View {
  Form {
    Text(self.store.count.description)
    Button("Decrement") {
      self.store.send(.decrementButtonTapped) 
    }
    Button("Increment") { 
      self.store.send(.incrementButtonTapped) 
    }
  }
}

And to get the full application building let’s update the preview to supply a store:

#Preview {
  ContentView(
    store: Store(initialState: CounterFeature.State()) {
      CounterFeature()
    }
  )
}

As well as the entry point of the application:

@main
struct ObservableExplorationsApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView(
        store: Store(
          initialState: CounterFeature.State()
        ) {
          CounterFeature()
        }
      )
    }
  }
}

We now have a very basic Composable Architecture feature implemented, and we have integrated it into a view in the most naive way possible. We are just reaching directly into the store to grab state, and we are directly sending actions to it.

And we would hope this works, and amazingly it absolutely does. We can run it in the preview and see that incrementing and decrementing work just as we hope.

Even smarter observation

And this is how we wanted to build the Composable Architecture from the very beginning. If you go back and watch the very early episodes, going all the way back to 2019, you will see that we didn’t introduce the concept of a “view store” until quite late. We were always hoping there would be some way to avoid it, but we were never able to crack the problem. And now the new observation tools in Swift 5.9 completely blows open the flood gates.

Brandon: But of course this feature isn’t very interesting right now. Let’s spruce it up a bit.

Let’s make it so that the view can toggle whether or not it is currently displaying the count. We saw in past episodes this was a great way to test just how smart the new observation tools are by showing that the view will subscribe to state changes if it is used in the view, but then unsubscribe from those changes dynamically if the state ceases to be displayed.

That was pretty cool, so let’s see if the Composable Architecture can benefits from those smarts too.

I’m going to add some state to our feature that determines if the view should be displaying the count or not:

@ObservableState
struct State {
  …
  var isObservingCount = true
}

As well as a new action for toggling that value:

enum Action {
  …
  case toggleIsObservingCount
}

And we can implement the logic easily in the reducer:

case .toggleIsObservingCount:
  state.isObservingCount.toggle()
  return .none

Then we can start using the state in the view to determine whether or not to show the count:

if self.store.isObservingCount {
  Text(self.store.count.description)
}

As well as add a button to send the new action:

Button("Toggle count observation") {
  self.store.send(.toggleIsObservingCount)
}

Those few steps are all we need to do to make the feature work. We can run it in the preview to see that everything works just as we would hope.

But even better, SwiftUI is automatically observing the bare minimum of state in the view. When the count is visible, it will observe changes to that state, but when we toggle the count off, the view will stop re-computing whenever that state changes.

It’s incredible, but it’s true. Let’s put a _printChanges in the view:

var body: some View {
  let _ = Self._printChanges()
  …
}

And run the preview again. Each time we increment or decrement the count we do see that the ContentView re-renders:

ContentView: @dependencies changed.
ContentView: @dependencies changed.
ContentView: @dependencies changed.

And if we toggle off the count we will get one more render:

ContentView: @dependencies changed.

But now each time we increment or decrement we do not see anything printed to the console. The view sees that the count state is no longer be used in the view, and so it no longer needs to invalidate and re-render when it changes.

If we wanted to accomplish smart observation like this with the old Composable Architecture tools we’d have to do some pretty strange things. We would create a dedicated view state struct that holds onto some optional integer state:

struct ViewState {
  let count: Int?
  init(state: CounterFeature.State) {
    self.count = state.isObservingCount ? state.count : nil
  }
}

And then we would need to use WithViewStore to observe that version of the state:

WithViewStore(
  self.store, observe: ViewState.init
) { viewStore in 
  …
}

And then we would need to unwrap that state:

if let count = viewStore.count {
  Text(count.description)
}

It’s just a really bizarre way to do things. It seems far more natural to simply make use of the state directly in the view, and have the view observe just the state that is accessed.

It’s absolutely incredible to see it actually work.

But are you ready for something even more incredible?

Let’s drop the deployment target of this project all the way down to iOS 15. And let’s choose an iOS 15 simulator.

Wouldn’t it be amazing if this all somehow magically worked. We are targeting a version of iOS that was released in 2019, which is over 4 years old.

This version of iOS long predates iOS 17, Swift 5.9, macros, or any of these fancy features that we think are required to make observation work in SwiftUI. And there are still a lot of people who need to support iOS 14, 15 and 16, and it won’t be for many more years before they can support iOS 17 and higher.

And so is it actually possible that this could work? Well, the project does compile, so that seems really promising. But, if we run it in the simulator we will see that unfortunately it does not work. But, notice that we are getting a purple warning in Xcode:

🟣 RuntimeWarnings.swift:4 Observable state was accessed but is not being tracked. Track changes to store state in a ‘WithPerceptionTracking’ to ensure the delivery of view updates.

It turns out that there is one additional step you have to take to make this work on pre-iOS 17 devices, and the library can usually detect when you aren’t doing it correctly in order to let you know.

All we have to do is wrap our view in this special new kind of view called a WithPerceptionTracking:

struct ContentView: View {
  …
  var body: some View {
    WithPerceptionTracking {
      let _ = print("\(Self.self).body")
      …
    }
  }
}

The WithPerceptionTracking view is a part of an internal library in the Composable Architecture that has our back port of the Observation framework so that it works on pre-iOS 17. We call it Perception, and someday we may even split it out of the Composable Architecture so that it can be used in vanilla SwiftUI applications too. But that will have to wait for another day.

And that’s all it takes to have the view automatically observe any state accessed in the view. We can run the app in the simulator and see that everything is working in the view, and further the view is continuing to re-compute in the most minimal way possible. If we are not displaying the count in the view, then changes to the count will not cause the view to re-compute. And this is all working on an iOS 15 device. And if we had a iOS 14 or even iOS 13 simulator installed we would see that it even works on those old platforms.

And this may seem like a bit of a step back that we had to use this WithPerceptionTracking view to wrap our core view. After all, we were able to completely get rid of the concept of the WithViewStore in iOS 17, and so isn’t it a bummer that we need this in pre-iOS 17?

Well, personally we don’t think it’s that big of a deal. It is not really that similar to the WithViewStore we had to use previously:

  • First of all, you don’t need to provide it a store and explicitly tell it what state you want to observe. All of that is taken care of for you.
  • Second, the trailing closure does not take an argument, and helps not strain the Swift compiler.
  • Third, if you accidentally forget to use this view you get a helpful warning in Xcode letting you know that’s not right.
  • And fourth, this is only needed if you are supporting pre-iOS 17 devices, and we just think it’s a small price to pay to have all the power of observation at your fingertips without needing to wait years.

Next time: Naive observation

OK, that was a fun sneak peek at what the final tools will offer you. But I don’t think our viewers are watching this episode right now just to show how to use the new tools. They can check out the observation-beta branch and all of the demos and case studies to get that information.

Stephen: Instead, I think our viewers are a lot more interested in learning how we accomplished all of this in the library. That is where we get to show off some really advanced techniques in the Swift language, and help people get a deeper understanding of how the library works. One of the best things about the Composable Architecture is not just that it’s a nice library to use, but also that nearly every decision that went into designing it the way that it is is well documented in this video series. And we’re not going to change that now.

So, let start by trying to naively integrate the Observation framework into the Composable Architecture as it exists in its last public release, and see how far we can get before we run into problems. That will help us figure out where we need to deviate from Apple’s tools in order to achieve our goals.

This episode is free for everyone.

Subscribe to Point-Free

Access all past and future episodes when you become a subscriber.

See plans and pricing

Already a subscriber? Log in