Video #260: Observable Architecture: Structural Identity
Episode: Video #260 Date: Dec 4, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep260-observable-architecture-structural-identity

Description
One of the core tenets of the Composable Architecture is that you should be able to model your application’s state using simple value types, like structs and enums. However, the @Observable macro does not work with value types at all, and so in order to enhance the Composable Architecture with Observation we will need to contend with this issue and explore what it means for a struct to be observable.
Video
Cloudflare Stream video ID: d2ad5f0f281a4bf7d4e81d802003afc2 Local file: video_260_observable-architecture-structural-identity.mp4 *(download with --video 260)*
Transcript
— 0:05
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
— 0:20
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.
— 0:46
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. Observing structs naively
— 1:04
I’ve got the Composable Architecture project open, and we are currently pinned to the latest release, which at the time of this recording is 1.5.0 . And I’ve got the Case Studies target selected because I want to start by trying to naively apply some of Apple’s observation tools to a simple feature and see what goes wrong.
— 1:20
For example, in this file we have the most basic kind of Composable Architecture feature, that of the humble counter. And currently it is making use of WithViewStore in order to observe the state in the feature: var body: some View { WithViewStore( store, observe: { $0 } ) { viewStore in … } }
— 1:34
We would love if we could completely delete the WithViewStore and just use the store like so: var body: some View { HStack { Button { store.send(.decrementButtonTapped) } label: { Image(systemName: "minus") } Text("\(store.count)") .monospacedDigit() Button { store.send(.incrementButtonTapped) } label: { Image(systemName: "plus") } } }
— 1:47
This is the more simple, naive way of approaching the view, and we would love if it somehow just magically worked.
— 1:52
Of course, currently this does not compile because generally speaking it is not legitimate to directly access state inside the store like this. If we allowed this we would have had to allow for one of two things:
— 2:02
Either all the state in the store is observed, leading to over-rendering of our views when state changes that the view doesn’t care about.
— 2:11
Or no state is observed, meaning the view would never re-render when state changes, and that is why the concept of a “view store” was created.
— 2:22
But the new Observation framework in Swift 5.9 should give us a happy medium between these two extremes. By default no state is observed in the view, but the moment the view touches a piece of state, then it is observed.
— 2:33
And we would have absolutely loved if the @Observable macro could be applied to structs like so: @Observable struct State { … }
— 2:41
But sadly this does not work: ‘@Observable’ cannot be applied to struct type ‘State’
— 2:44
At the last moment during the proposal for Observation, the core team decided to disallow the macro from being applied to structs, while also stating that they hope someday they will allow for this.
— 2:53
And we discussed this topic at length a few weeks ago in our deep dive of the Observation framework. We showed that by naively allowing it to be applied to structs we get into some really strange situations that are currently not solvable in Swift. The short of it is that value types are meant to be copied around without a care in the world, which is very different from classes, and so the question is: how should observation behave on those copies?
— 3:13
Should the observation registrars be cleared out when copies are made? And when you wholesale assign one value to another, should their registrars be merged? There’s a lot of open questions, and depending on what you decide is the best answer there is a very good chance that Swift today cannot even implement it.
— 3:27
So, it seems a little hopeless to apply observation to structs, but hopelessness has never stopped us before on Point-Free. In those past episodes we pushed forward by manually writing the code that the macro would have written for us to see how far we could push the idea of observable structs. We highly recommend everyone watch that episode, but let’s try pushing our struct into the observable world, but in an ad-hoc manner.
— 3:48
So, let’s manually write all the code that the @Observable macro writes for us. We can start by conforming our State struct to the Observable protocol: struct State: Equatable, Observable { … }
— 3:53
That is completely fine because the core Swift team specifically decided to not constrain the protocol to only classes because they really do want the observation machinery to work for structs someday.
— 4:03
Next we can add the observation registrar to the State : private let _$observationRegistrar = Observation.ObservationRegistrar()
— 4:13
Remember that the registrar is the tool that keeps track of what fields have been accessed on your type, and when those fields are mutated the registrar broadcasts that out to anyone interested.
— 4:20
Next we can swap out the one stored property we have for a computed property, and then add a new stored property that is underscored: private var _count = 0 var count: Int { }
— 4:30
And then the get , set and init accessors of this computed property will let the observation registrar know what’s going on: var count: Int { @storageRestrictions(initializes: _count) init(initialValue) { _count = initialValue } get { self._$observationRegistrar.access(self, keyPath: \.count) return _count } set { self._$observationRegistrar.withMutation(of: self, keyPath: \.count) { _count = newValue } } }
— 4:42
And that’s all it takes.
— 4:44
So, that makes our State struct quote-unquote “observable”, but it still doesn’t make our view magically work. We still are not allowed to access state directly in the store.
— 4:54
But, what if we relaxed that requirement a bit when you are dealing with stores of observable state. After all, observable structs should be able to communicate with the view automatically when state is accessed and mutated, and so there should be no harm in reaching into the store and grabbing whatever state you want.
— 5:07
Well, it is possible to do this, and it’s even pretty easy. Let’s add a new Store+Observation.swift file where we will add any new observation-specific tools.
— 5:22
And then in here we can extend the Store type specifically when the underlying state is Observable : import Observation extension Store where State: Observable { }
— 5:29
But because the Observable protocol is restricted only to iOS 17 and newer we have to add an availability check to this extension: @available(iOS 17.0, *) extension Store where State: Observable { }
— 5:36
But this is going to be a pain. For a moment let’s forget about any pretense of keeping backwards compatibility with older versions of the library, and let’s just update the package to be Swift 5.9: // swift-tools-version:5.9
— 5:46
And restrict the platform to iOS 17: platforms: [ .iOS(.v17), … }
— 5:50
We of course will not do this in the final version of these tools because we don’t want to drop support for older versions of Swift and iOS, but for the purpose of these episodes we don’t want to get bogged down in those details. Now we get to extend with no availability checks: extension Store where State: Observable { }
— 5:59
And so in this specific case we will allow people to access the state directly. To do this we will add a computed property to the store that gives full access to the state, and that is done by reaching into the current value subject that is at the heart of the store: extension Store where State: Observable { public var state: State { self.stateSubject.value } }
— 6:19
But directly accessing state in the store isn’t very common. Usually we are accessing a property from the state, and that is why view stores support dynamic member lookup. That allows you to do: viewStore.count …rather than: viewStore.state.count
— 6:33
To allow this kind of syntax for stores we need to mark the Store class with @dynamicMemberLookup : @dynamicMemberLookup public final class Store<State, Action> { … }
— 6:45
And provide a dynamic member when the State is observable: extension Store where State: Observable { public subscript<Member>( dynamicMember keyPath: KeyPath<State, Member> ) -> Member { self.state[keyPath: keyPath] } } This will allow us to do store.count rather than store.state.count .
— 7:04
And with just that tiny bit of library code added I think our case study is now compiling. And if we run the preview we will see that it works as we expect. We can count up and down.
— 7:15
Now, at face value this may not seem very impressive, but it is actually quite interesting. We are allowing unfettered access to the state in the store, which essentially has no observation mechanism whatsoever. The store is not doing anything to observe the state it holds.
— 7:29
But, thanks to the fact that we have installed a registrar in our state and pinged it anytime state is accessed or changed, the view is immediate seeing the changes and notifying the view it is time to update.
— 7:42
And this observation mechanism is dynamic. If something caused the view to stop showing the count , then changes to the count would no longer cause the view to invalidate and re-render.
— 7:50
Let’s demonstrate this by adding some state to our feature that determines whether or not we are displaying the count: struct State: Equatable, Observable { … var isDisplayingCount = true }
— 7:59
However, this is too simplistic. Changes to this boolean will not be observed by the view since we are not interacting with the observation registrar when the field is accessed or mutated.
— 8:09
Now, it’s a pain to write out all of that boilerplate, and it’s one of the main reasons why the macro is so handy, but we still can’t use it. So, we will copy-and-paste the count property and rename things to isDisplayingCount : private var _isDisplayingCount = true var isDisplayingCount: Bool { @storageRestrictions(initializes: _isDisplayingCount) init(initialValue) { _isDisplayingCount = initialValue } get { self._$observationRegistrar.access( self, keyPath: \.isDisplayingCount ) return _isDisplayingCount } set { self._$observationRegistrar.withMutation( of: self, keyPath: \.isDisplayingCount ) { _isDisplayingCount = newValue } } }
— 8:23
And for some reason the project now doesn’t compile. It seems that in places we try to construct this state it is asking us to provide isDisplayingCount even though we have provided a default value. We think this is a bug in Swift, and it seems the only work around is to provide an explicit initializer. Luckily Swift does fill in the initializer for us pretty easily: init(count: Int = 0, isDisplayingCount: Bool = true) { self._count = count self._isDisplayingCount = isDisplayingCount }
— 8:54
We will further add an action in order to toggle this boolean: enum Action: Equatable { … case toggleIsDisplayingCount }
— 9:02
…and implement the action in the reducer: case .toggleIsDisplayingCount: state.isDisplayingCount.toggle() return .none
— 9:10
Then in the view we scan stop displaying the count when the boolean is false : if store.isDisplayingCount { Text("\(store.count)") .monospacedDigit() }
— 9:17
And we can add a button for toggling the boolean: Button("Toggle count display") { store.send(.toggleIsDisplayingCount) }
— 9:26
And let’s put in a VStack to make this look a little better in the preview: VStack { HStack { … } Button("Toggle count display") { store.send(.toggleIsDisplayingCount) } }
— 9:29
And we will add _printChanges to the view so that we can see whenever the body recomputes: var body: some View { let _ = Self._printChanges() … }
— 9:45
With that done what we will find is that while the count is being displayed, each change to the count does indeed make the view recompute its body. However, once we toggle the display of count off, changes no longer cause the view to recompute. The view is smart enough to unsubscribe from changes to the count once it is no longer being displayed in the view, and that’s pretty incredible.
— 10:05
Technically we should even be able to drop the Equatable conformance from State : struct State: Observable /*, Equatable*/ { … }
— 10:13
…since it is no longer used for de-duplicating state changes in the view. However, in practice we will probably still want this because it is very important for tests.
— 10:20
So, this is seeming pretty great, but unfortunately it is far too naive to actually work in practice. As we mentioned in our previous episodes on observation, observable structs are a very thorny concept. It is very easy to make copies of structs and consequently break our expectations of how observation works with those copies.
— 10:36
And we can show that very easily. Let’s add an action that resets the state of the feature: enum Action: Equatable { … case resetButtonTapped }
— 10:44
And we’ll implement that action by wholesale replacing our feature’s state with a brand new value: case .resetButtonTapped: state = State() return .none
— 10:52
Note that we aren’t just resetting the count back to zero. We want to make sure to reset everything back to its defaults, and the best way to do that is to just create a whole new State value from scratch.
— 11:01
And finally, we’ll add a button to the view: Button("Reset") { store.send(.resetButtonTapped) }
— 11:11
Well, unfortunately that completely breaks our feature. The increment and decrement works just fine at first, but as when we tap “Reset” nothing seems to happen. And even stranger, tapping increment and decrement no longer work now. And also nothing is logged to the console saying that the view’s body was recomputed when we tap any buttons.
— 11:30
This is happening because when we do this: state = State()
— 11:34
…we are creating a whole new observation registrar under the hood, which means we have completely cleared out the access list that was built up in the previous state. And so when we make mutations to state, there is no one to notify of those changes. If something were to cause the view to re-render just once more, that would give the view a chance to re-build its access list and start subscribing to changes again.
— 11:51
We also showed this exact problem a few weeks ago when discussing observable structs. I’ve got that project open, and we are in the file where we explored the concept of observable structs.
— 12:02
In that episode we worked around the problem by wrapping the value type in an Observable reference type, and differentiating between the act of wholesale setting the value type versus simply modifying the value in place. And we were able to distinguish between these two situations by using Swift’s _modify accessor: var state: CounterState { get { self.access(keyPath: \.state) return self._state } set { withMutation(keyPath: \.state) { self._state = newValue } } _modify { yield &self._state } }
— 12:21
In particular, when we see that we are simply modifying the state in place, we did not invoke withMutation since the observable reference type doesn’t care about that mutation, and the observable struct will take care of invoking withMutation . But if we wholesale replace the value then that is something the observable struct alone cannot witness, but the reference type can, and so we will make sure to tell the registrar about that mutation.
— 12:30
So, can we do something similar in the Composable Architecture? Well, we certainly don’t want to just add a _modify to the count computed property directly in Counter.State : get { self._$observationRegistrar.access( self, keyPath: \.count ) return _count } set { self._$observationRegistrar.withMutation( of: self, keyPath: \.count ) { _count = newValue } } _modify { yield &self._count }
— 12:52
We can immediately see that this just breaks observation of the count completely in the preview. If we toggle the count off and on that works, and then we see that secretly the count was updated under the hood. We just didn’t see any of those changes when we tapped the increment or decrement buttons.
— 12:59
And this is because this does not actually detect between doing something like this: state.count += 1
— 13:08
…in the reducer versus doing something like this: state = State()
— 13:10
And that’s because at the end of the day this state is held in the Store reference type, and the store runs the reducer on the state as an inout parameter, and so typically only the _modify is called, never the set . And that’s why we aren’t actually observing anything in the view now.
— 13:22
So I guess what we have to do is figure out somewhere higher up in the library to install this modify logic. We do have a reference type that wraps all feature states, and that is the Store . The source of truth for the store’s state is held in this CurrentValueSubject : @_spi(Internals) public var stateSubject: CurrentValueSubject<State, Never>
— 13:36
Unfortunately we cannot insert the _modify accessor into this because the Combine subject is out of our control. We could introduce some indirection to achieve this, like if we had an internal, computed property for state that delegates to the subject: extension Store { var observableState: State { get { self.stateSubject.value } set { self.stateSubject.value = newValue } _modify { yield &self.stateSubject.value } } }
— 14:11
And we would want to instrument these accessors with calls to the observation registrar.
— 14:15
But before doing that, there’s a few places we should update to use this new property instead of the subject. For example, in the state property, which remember is only available when State is Observable : extension Store where State: Observable { public var state: State { self.observableState } }
— 14:31
Further, anywhere we mutate stateSubject.value we should go through the observableState property. This includes in the send method: // self.stateSubject.value = currentState self.observableState = currentState …as well as the scope operation: // childStore.stateSubject.value = childState childStore.observableState = childState
— 14:51
OK, so everything should behave just as it did before, both in terms of where things functioned correctly and where things broke down. All we’ve done is inserted a little bit of indirection into our store so that we know when the state in the store is accessed and mutated.
— 15:02
But that means we can now instrument the access and mutation of state in the store. Now the Store is a reference type, and so we could technically use the @Observable macro, but that isn’t the right thing to do here. We don’t have a single property we are wanting to observe, but rather have a CurrentValueSubject that we have defined a custom computed property for. And so the macro will not help us with that.
— 15:19
But that’s OK, we can just manually recreate what the macro does for us. We will add an observation registrar to the Store : let _$observationRegistrar = ObservationRegistrar()
— 15:31
We’ll conform the Store to the Observable protocol, which is easy to do since it doesn’t have any requirements whatsoever: extension Store: Observable {}
— 15:41
And then inside the new observableState computed property we will communicate with the registrar when the state has been accessed or mutated: var observableState: State { get { self._$observationRegistrar.access( self, keyPath: \.observableState ) return self.stateSubject.value } set { self._$observationRegistrar.withMutation( of: self, keyPath: \.observableState ) { self.stateSubject.value = newValue } } _modify { yield &self.stateSubject.value } }
— 16:15
And with that done, things will start working again. We can count up and down, toggle displaying the count, and tapping the “Reset” button does correctly put everything back into its default state. And we can continue counting up and down again even after the reset.
— 16:35
So, that seems good, but things are still not quite right. First of all, the _modify in this computed property is not pulling its weight at all. It does not have the same effect it did over in our observable struct demo from a few episodes back.
— 16:46
In fact, this _modify is never even called. We can put a breakpoint in there, run the app in the simulator, and we will see it never catches. And this is because the store actually never modifies the state in place, but rather modifies a piece of scratch state in place: let effect = self.reducer.reduce( into: ¤tState, action: action )
— 17:21
…and then wholesale assigns that scratch state to the store’s state: self.observableState = currentState
— 17:28
The reason for this dance isn’t super important, but it does allow the Store to coalesce mutations from several actions received at the same time into a single mutation. However, this isn’t even the problem. If we mutate observable state directly: // var currentState = self.stateSubject.value … // self.observableState = currentState … let effect = self.reducer.reduce( into: &self.observableState, action: action )
— 17:46
…we end up with the opposite problem, where set is never called and _modify is always called due to us passing the property to the reducer as inout . The assignment happening in resetButtonTapped just can’t be distinguished by the store as a set vs. a _modify , so we can’t rely on the trick that helped us in our observable struct demo from a few episodes back.
— 18:06
So let’s go back to the existing dance in Store.send .
— 18:09
And since this _modify isn’t pulling its weight, let’s remove it: // _modify { // yield &self.state.value // }
— 18:15
So, it seems that the thing that helped us in our observable struct demo from a few episodes back just doesn’t work at all in the Composable Architecture.
— 18:20
And this does have a negative consequence. While it does seem like our counter feature is working, it unfortunately is over-observing state. We can see that if we turn off the count display, and tap the “+” and “-” buttons that the view is re-computing its body, even though no state is being displayed at all.
— 18:45
And it shouldn’t be too surprising that this is happening. After all, anytime the Store ’s state is accessed we register it with its observation registrar: get { self._$observationRegistrar.access( self, keyPath: \.observableState ) return self.state.value }
— 18:53
And then as soon as the Store ’s state is changed we always tell the registrar: set { self._$observationRegistrar.withMutation( of: self, keyPath: \.observableState ) { self.state.value = newValue } }
— 18:57
So, we fixed one problem, that of the wholesale replacement of state not being observed properly, but then created a new problem, that of over-observing state. Observing structs with identity
— 19:05
We seem to be back to square one. We’ve done all this work to try to integrate our feature and the Store into Swift’s observation machinery, but at the end of the day it seems like we are just observing all state changes, regardless of what is being used in the view.
— 19:18
Well, it may not seem like it, but we are making progress towards our goals. There are just some bumps in the road along the way. The problem right now is that our Store observation code is far too naive. We don’t want to tell the registrar that a mutation was made when any change was made to the state. Most changes are already covered by the registrar logic installed in the feature’s state. Brandon
— 19:36
The only changes the store actually needs to tell the registrar about are when a reducer wholesale replaces the state with a new version of the state. Those are the kinds of mutations that the registrar in each feature cannot detect, and only the store can detect.
— 19:52
But in order to do that we need some notion of identity for our state structs. Identity of course makes a lot of sense when talking about reference types, and that’s one of the reasons why Swift’s observation tools were limited to only reference types. But identity for values makes less sense because value types can be copied around willy-nilly, without a care in the world.
— 20:11
So, let’s try to see what it would mean to bring a sense of identity to our feature’s states, and see how that might help us stop over observing our state.
— 20:23
We could approach this in the most naive way possibly by literally giving our feature’s State struct an identity by just holding onto a UUID: struct State: Equatable, Observable { let id = UUID() … }
— 20:44
Now id is probably too common of an identifier for us to squat on, so let’s follow the example set by the @Observable macro by prefixing it with _$ : let _$id = UUID()
— 20:58
With such a property added we now have a very clear way to see when a piece of state is mutated in place versus wholesale recreated. For example, if we created a piece of state, made some mutations, then we would be certain that its “identity” did not change: var state = State() let originalState = state state.count += 1 state.isDisplayingCount.toggle() state._$id == originalState._$id
— 21:52
Whereas if we created a whole new piece of state with all the same data as the current state, then the identity definitely would change: let newState = State( count: state.count, isDisplayingCount: state.isDisplayingCount ) newState._$id != state._$id // identity changed
— 22:38
So this is starting to seem like we have a way to approximate what the _modify accessor gave us in our observable struct demo. We can now distinguish between when a piece of state is mutated in place versus wholesale replaced.
— 22:58
However, in order to take advantage of this at the Store level we need some way for the store to generically detect when it is dealing with structs that have a notion of identity. So, we can create a protocol that represents state structs with identity: import Foundation public protocol ObservableState: Observable { var _$id: UUID { get } }
— 23:44
And then this is the protocol we will actually use in the Composable Architecture, not the plain Observable protocol. For example, when we add the dynamic member subscript we will constrain against this protocol: extension Store where State: ObservableState { … }
— 24:03
And now we have a way of determining in the store when it is appropriate to notify the observation registrar that something has happened with the state.
— 24:11
For example, in the get accessor we can check if the store’s State is observable, and only in that situation will we record the access of the state: get { if State.self is ObservableState.Type { self._$observationRegistrar.access( self, keyPath: \.observableState ) } return self.stateSubject.value }
— 24:38
And in the set accessor we can check if the state is ObservableState . If it’s not, then we should always notify the observation registrar because we have no notion of “identity” that could help us elide calls to withMutation : set { if let old = self.stateSubject.value as? any ObservableState, let new = newValue as? any ObservableState { } else { self._$observationRegistrar.withMutation( of: self, keyPath: \.observableState ) { self.stateSubject.value = newValue } } }
— 25:25
And further, if the state is ObservableState and the IDs match, then we elide the call to withMutation , which should hopefully help with some of the over observation problems we had: set { if let old = self.stateSubject.value as? any ObservableState, let new = newValue as? any ObservableState, old._$id == new._$id { self.stateSubject.value = newValue } else { self._$observationRegistrar.withMutation( of: self, keyPath: \.observableState ) { self.stateSubject.value = newValue } } }
— 25:57
Seems like wild stuff, but that is all it takes.
— 26:01
The project isn’t compiling, but that’s just because we now have to conform the Counter.State to the ObservableState protocol: struct State: Equatable, ObservableState { … }
— 26:19
Everything is now compiling, and the counter feature works exactly as we want. All the buttons work, including the “Reset” button, and further we are observing the minimal amount of state. If we turn off the count display and increment the count we will see that the view does not re-compute its body at all.
— 26:55
So this notion of structural identity is pretty handy, and the _$id property could even be hidden inside some kind of @ObservableState macro: @ObservableState struct State: Equatable { … }
— 27:06
…along with all the boilerplate for each property of the State .
— 27:14
We aren’t going to do that just yet because there is still more to do. Although we are getting very close to a mostly functional version of the Composable Architecture using Swift’s observation tools, there are still a few problems.
— 27:29
To see a problem we need to turn to a more complex feature. We need a feature that composes other features together. Luckily we have a very simple case study to demonstrate this, and it’s called TwoCounters . We can run the preview and see that the demo does exactly what it says on the tin. There are two counters, and they each run independently.
— 28:06
The reducer is quite simple. It just holds the state and actions for two Counter features: struct State: Equatable { var counter1 = Counter.State() var counter2 = Counter.State() } enum Action: Equatable { case counter1(Counter.Action) case counter2(Counter.Action) }
— 28:18
And composes the two reducers together: var body: some Reducer<State, Action> { Scope(state: \.counter1, action: \.counter1) { Counter() } Scope(state: \.counter2, action: \.counter2) { Counter() } }
— 28:29
And finally displays two CounterView ’s in the view, one on top of the other, using the scope operation on stores: HStack { Text("Counter 1") Spacer() CounterView( store: store.scope( state: \.counter1, action: \.counter1 ) ) } HStack { Text("Counter 2") Spacer() CounterView( store: store.scope( state: \.counter2, action: \.counter2 ) ) }
— 28:45
Technically this view doesn’t even need a WithViewStore because TwoCountersView is not accessing state at all. It’s just scoping child stores to hand off to CounterView , but it doesn’t need to directly display any state in the view.
— 28:58
And that means the TwoCountersView should only compute its body a single time, when it first appears. None of the changes that happen in each individual CounterView should cause this parent view to re-compute.
— 29:12
And we can prove this to ourselves by adding a _printChanges to the view: var body: some View { let _ = Self._printChanges() … } And running the preview to confirm that is indeed the case.
— 29:35
Ideally if we upgraded TwoCounters.State to be observable state we should have the same behavior. The TwoCountersView should never re-compute its body after the first time. But, if in the future we did start accessing some state in the view, then we might pick up some additional body re-computations, and that would be completely fine.
— 29:57
In fact, let’s do just that. Suppose that in the parent view, which is the TwoCountersView , we wanted to show the sum of the two child counters: Section { Text("Sum: \(store.counter1.count + store.counter2.count)") }
— 30:17
And further, what if we wanted the display of this information to be dynamic. We could add some state to our domain: struct State: Equatable { … var isDisplayingSum = true }
— 30:29
As well as an action for toggling that new state: enum Action: Equatable { … case toggleSumDisplay }
— 30:33
And handling that action: Reduce { state, action in switch action { case .counter1: return .none case .counter2: return .none case .toggleSumDisplay: state.isDisplayingSum.toggle() return .none } }
— 31:06
And in the view we could use that state to determine if we show the sum or not: Section { if store.isDisplayingSum { Text("Sum: \(store.counter1.count + store.counter2.count)") } }
— 31:17
And finally we will add a button for toggling the sum on and off: Button("Toggle sum") { store.send(.toggleSumDisplay) }
— 31:25
However, none of this compiles of course because we are not allowed to freely reach into the store and grab whatever state we want. We are only allowed to do this if our state is observable.
— 31:37
Well, we can certainly cheat a bit by providing a quick-and-dirty conformance to the ObservableState protocol: struct State: Equatable, ObservableState { let _$id = UUID() … }
— 31:52
We haven’t gone through all the motions to make sure that each field of the state is probably instrumented with an observation registrar, but let’s see if things mostly work like this.
— 32:08
Certainly incrementing and decrementing each counter works just as it did before, but interesting even the sum seems to be working. Each time we increment one of the counters, the sum also goes up by one. And the views are re-computing in the minimal way possible. When one counter is incremented we see that exactly one CounterView is re-computed, as well as the TwoCountersView : CounterView: @dependencies changed. TwoCountersView: @dependencies changed.
— 32:36
How is this working even though we haven’t done all the work to properly observe counter1 and counter2 in the parent domain?
— 32:43
Well, let’s add symbolic breakpoints for both Observation.ObservationRegistrar.access and Observation.ObservationRegistrar.withMutation .
— 33:09
These symbolic breakpoints are a great debugging tool for figuring out observation-related problems.
— 33:14
And then let’s run the app in the simulator and navigate to the “Combining Reducers” demo.
— 33:26
We immediately get caught in the access breakpoint, and from the stack trace we can see it is coming from the TwoCountersView , and it’s when accessing the isDisplayingSum state. We haven’t even instrumented isDisplaySum with the registrar yet, and so this access is actually coming from the dynamic member lookup on the observableState property in the store, which we can see higher in the stack trace.
— 34:02
We can continue execution 4 more times to see 4 more accesses in the TwoCountersView :
— 34:08
We get caught in access from accessing store.counter1 in the TwoCounters domain, which again is coming from the dynamic member lookup, just like isDisplayingSum .
— 34:20
We get caught on access again , but this time from accessing counter1.count inside the counter domain, and this is happening with the local registrar in the counter state.
— 34:53
And then this pattern happens all over again for counter2 . And if we keep hitting continue we will see that we get a few more accesses, but these are all from within the CounterView , which is the child domain of the TwoCounters feature.
— 35:14
This means that within the scope of the TwoCountersView computing its body it has tracked access to each counter state’s count field. Which is a good thing because the TwoCountersView is definitely interested in that state since it wants to sum the values together. And so it would not be surprising that any mutation to those fields would trigger a re-render of the view.
— 35:38
Let’s disable the access breakpoint now, and then tap the “+” button in the first counter. We will see that withMutation is called a single time in the child feature, from the CounterView , and that is all. But that is enough. Because the TwoCountersView registered its interest in counter1.count and counter2.count , it is now going to be notified when the child domain makes mutation to its state.
— 36:17
And if we comment out the sum display: // Text("Sum: \(store.counter1.count + store.counter2.count)")
— 36:30
Then incrementing or decrementing the counters does not cause the TwoCountersView to re-compute at all.
— 36:40
So, this is seeming great. It’s seeming like even without doing any of the individual instrumentation of each property of this feature’s state we are getting a lot of the benefits.
— 37:02
But of course it’s too good to be true. Everything seems to be working, but it’s really only by accident. What we have done so far is a little too simplistic to fully work. Let’s bring back the sum display: Text( """ Sum: \( store.counter1.count + store.counter2.count ) """ ) And let’s try toggling the sum on and off from the UI.
— 37:17
We will see that unfortunately the view does not update at all. And this shouldn’t be too surprising since we are not doing anything to notify an observation registrar when the isDisplayingSum state is accessed or mutated.
— 37:33
So, it looks like we do at least have to observe changes to that field, but perhaps we don’t need to observe counter1 and counter2 somehow since they are observable themselves. Let’s try it out. We’ll add a registrar to TwoCounters.State : private let _$observationRegistrar = Observation.ObservationRegistrar()
— 37:52
And we’ll swap out the stored property isDisplayingSum for a stored and computed duo so that we can notify the registrar when that piece of state is accessed and mutated: private var _isDisplaySum = true var isDisplaySum: Bool { @storageRestrictions(initializes: _isDisplaySum) init(initialValue) { _isDisplaySum = initialValue } get { self._$observationRegistrar.access( self, keyPath: \.isDisplaySum ) return _isDisplaySum } set { self._$observationRegistrar.withMutation( of: self, keyPath: \.isDisplaySum ) { _isDisplaySum = newValue } } }
— 38:06
And without any other changes the preview does seem to work as we expect. We can toggle the sum on and off, and the UI updates accordingly. And even cooler, if we turn the sum display off, then any change to either counter does not cause TwoCountersView to re-compute, but turning it back on does. So our view is dynamically figuring out which state to observe.
— 38:40
However, this is still not quite right. It would have been terrific if we didn’t need to do anything special for the counter1 and counter2 fields since they are observable themselves, but unfortunately that is too hopeful of thinking. A problem arises when wholesale resetting either field from the parent reducers.
— 39:04
In fact we can see the behavior right now, where if we increment a counter, the sum is updating, but if we reset the same counter, it goes to zero but the sum does not reset, and incrementing the counter all over again does not have any effect on the sum. We have completely broken the UI again.
— 39:26
Well, this is similar to the problem we saw in the store a moment ago, and that we saw when we first explored “observable structs.” This is happening because by constructing new state like this: state.counter1 = Counter.State()
— 39:34
…we are giving the child domain a whole new observation registrar. But, the parent view did not re-compute its body again in order to see the new registrar, and so it will only ever be notified by the old, and that will never happen.
— 39:48
We can do something to give it a kick in the butt by toggling the sum off and on, and then we will see that everything goes back to working again. This is only because now the parent view has had an opportunity to interact with the new registrar in the child feature.
— 40:05
So unfortunately it was far too hopeful for us to think that we could simply not observe counter1 and counter2 state. We do need to observe that state so that if anyone ever wholesale replaces that state the view can be notified and get access to any new registrars.
— 40:24
So, let’s do this in the most obvious way, by swapping out the stored properties of counter1 and counter2 with duos of stored and computed properties: var _counter1 = Counter.State() var counter1: Counter.State { @storageRestrictions(initializes: _counter1) init(initialValue) { _counter1 = initialValue } get { self._$observationRegistrar.access( self, keyPath: \.counter1 ) return _counter1 } set { self._$observationRegistrar.withMutation( of: self, keyPath: \.counter1 ) { _counter1 = newValue } } } var _counter2 = Counter.State() var counter2: Counter.State { @storageRestrictions(initializes: _counter2) init(initialValue) { _counter2 = initialValue } get { self._$observationRegistrar.access( self, keyPath: \.counter2 ) return _counter2 } set { self._$observationRegistrar.withMutation( of: self, keyPath: \.counter2 ) { _counter2 = newValue } } }
— 40:44
Now when we run the preview it seems like everything is working correctly. We can increment the first counter, see the sum goes up, and then reset the first counter and see that the sum resets back to 0. And even better, it appears the view is still observing the minimal amount of state. When the sum is toggled off, any changes to either counter does not cause the TwoCountersView to re-compute its body.
— 41:26
It turns out it’s still not quite right, but we need to explore a more complex example before we see the problem. Next time: Observing optionality
— 41:36
We now have the very basics of support for Swift’s observation machinery in the Composable Architecture. Now we have barely scratched the surface so far, but we have overcome some of its biggest technical hurdles.
— 41:50
We have introduced the notion of identity for the state of our features, even though the state is a value type. That notion of identity gives us the ability to distinguish between when a piece of state is wholesale replace or simply mutated in place. And that is precisely what allows our views to observe only the state that the view touches, even though we are dealing with value types at every layer.
— 42:11
We saw previously that was basically impossible in vanilla SwiftUI due to the complications of value types, but because the Composable Architecture is a far more constrained system with a single reference type at its core, we are able to pull it off. And it’s pretty incredible. Stephen
— 42:25
But right now we have a bunch of code written that no one is ever going to want to write themselves. It’s very similar to the code that the @Observable macro writes for us when building vanilla SwiftUI applications, but there are a few tweaks we made, but also that macro is prohibited from being applied to structs.
— 42:43
So, it sounds like we need need our own macro that can write all of this code for us. And luckily for us the @Observable macro is a part of Swift’s open source project, and so we can basically copy-and-paste their macro over to our project, and make a few small tweaks.
— 42:56
Let’s give it a shot. Downloads Sample code 0260-observable-architecture-pt2 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .