Video #261: Observable Architecture: Observing Optionals
Episode: Video #261 Date: Dec 11, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep261-observable-architecture-observing-optionals

Description
The Composable Architecture can now observe struct state, but it requires a lot of boilerplate. Let’s fix this by leveraging the @Observable macro from the Swift open source repository. And let’s explore what it means to observe optional state and eliminate the library’s IfLetStore view for a simple if let statement.
Video
Cloudflare Stream video ID: df410503882a67dcdad67e71c7e04463 Local file: video_261_observable-architecture-observing-optionals.mp4 *(download with --video 261)*
Transcript
— 0:05
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.
— 0:18
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.
— 0:40
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
— 0:54
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.
— 1:12
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.
— 1:25
Let’s give it a shot. The @ObservableState macro
— 1:29
There is a directory in the Swift repo called “ ObservationMacros ”, and it houses all of the code necessary to implement the @Observable macro. It’s quite a bit of code because it handles many edge cases, so I would definitely prefer not to have to implement this macro from scratch.
— 1:43
I am going to copy-and-paste the directory over to the “ComposableArchitectureMacros” directory.
— 1:51
And I am going to delete the CMakeLists.txt file because we don’t need it.
— 1:56
And just with that the core library is compiling, which means all of that macro code is even compiling.
— 2:07
But we are going to want to make some changes. First of all, we want to have slightly different names for our macro so that we don’t have ambiguity problems for anyone using the Composable Architecture and using the vanilla @Observable macro.
— 2:18
We can rename ObservableMacro.swift to ObservableStateMacro.swift:
— 2:24
And if we hop over to ObservableStateMacro.swift we will find a few things we can change really easily. At the top of the file there are a bunch of configuration statics that specify the names of various things in the macro, such as the module the macro is in. We can update that to point to the Composable Architecture module: static let moduleName = "ComposableArchitecture"
— 2:39
Next we have the conformance name, which we will rename to ObservableState to match the protocol we created: static let conformanceName = "ObservableState"
— 2:44
Next we have the registrar type name, which we can leave as-is because our macro will use the exact same kind of registrar under the hood. When we release the final version of these tools this will need to be changed in order to be backwards compatible with iOS 16 and earlier, but for the purpose of this episode we don’t have to worry about it.
— 3:00
Next we have the names of the tracked and ignored macros, which we will rename to have the word “State” in them: static let trackedMacroName = "ObservationStateTracked" static let ignoredMacroName = "ObservationStateIgnored"
— 3:08
And let’s rename the macro types too: // public struct ObservableMacro { public struct ObservableStateMacro { … } … // public struct ObservationTrackedMacro: AccessorMacro { public struct ObservationStateTrackedMacro: AccessorMacro { … } … // public struct ObservationIgnoredMacro: AccessorMacro { public struct ObservationStateIgnoredMacro: AccessorMacro { … }
— 3:32
Then down in the implementation we are going to remove the diagnostic that prevents this macro from being applied to structs: // if declaration.isStruct { // // structs are not yet supported; // // copying/mutation semantics tbd // throw DiagnosticsError( // syntax: node, // message: """ // '@Observable' cannot be applied to struct type \ // '\(observableType.text)' // """, // id: .invalidApplication // ) // }
— 3:43
There are more changes we will want to make with this macro, but let’s get a quick test into place so that we can just see what we have accomplished so far. To do that we need to register the macros in our plugin entry point: @main struct MacrosPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ ReducerMacro.self, ObservableStateMacro.self, ObservationStateTrackedMacro.self, ObservationStateIgnoredMacro.self, ] }
— 4:03
And add the macro signatures to the Macros.swift file in the Composable Architecture. The signatures are quite complex, but we can copy-and-paste these from the open source code base and make a few small changes: @attached( member, names: named(_$observationRegistrar), named(access), named(withMutation) ) @attached(memberAttribute) @attached(extension, conformances: ObservableState) public macro ObservableState() = #externalMacro( module: "ComposableArchitectureMacros", type: "ObservableStateMacro" ) @attached( accessor, names: named(init), named(get), named(set) ) @attached(peer, names: prefixed(_)) public macro ObservationStateTracked() = #externalMacro( module: "ComposableArchitectureMacros", type: "ObservationStateTrackedMacro" ) @attached(accessor, names: named(willSet)) public macro ObservationStateIgnored() = #externalMacro( module: "ComposableArchitectureMacros", type: "ObservationStateIgnoredMacro" )
— 4:57
And with that I think we can write a basic test. We will create a ObservableStateMacroTests.swift file.
— 5:10
And we will paste in some basic scaffolding for the test: import ComposableArchitectureMacros import MacroTesting import XCTest final class ObservableStateMacroTests: XCTestCase { override func invokeTest() { withMacroTesting( isRecording: true, macros: [ ObservableStateMacro.self, ObservationStateIgnoredMacro.self, ObservationStateTrackedMacro.self, ] ) { super.invokeTest() } } func testBasics() { } }
— 5:22
Note that we have record mode turned on because we are going to be iterating on this macro quite a bit, and we want to see its freshest expansion each time we run the tests.
— 5:27
Now we can write a very basic test, and we can use our wonderful macro testing helper to make very quick and painless: func testBasics() { assertMacro { """ @ObservableState struct State { var count = 0 } """ } }
— 5:48
If we run this test we will exactly what the macro expands to directly in the test: func testBasics() { assertMacro { """ @ObservableState struct State { var count = 0 } """ } expansion: { #""" struct State { var count = 0 { @storageRestrictions(initializes: _count ) init(initialValue) { _count = initialValue } get { access(keyPath: \.count ) return _count } set { withMutation(keyPath: \.count ) { _count = newValue } } } private let _$observationRegistrar = ComposableArchitecture.ObservationRegistrar() internal nonisolated func access<Member>( keyPath: KeyPath<State , Member> ) { _$observationRegistrar.access( self, keyPath: keyPath ) } internal nonisolated func withMutation< Member, MutationResult >( keyPath: KeyPath<State , Member>, _ mutation: () throws -> MutationResult ) rethrows -> MutationResult { try _$observationRegistrar.withMutation( of: self, keyPath: keyPath, mutation ) } } """# } }
— 5:57
And this all looks pretty good! The count stored property has been replaced by a duo of stored and computed properties that communicate with the registrar.
— 6:08
Oh, well I do see one problem: private let _$observationRegistrar = ComposableArchitecture.ObservationRegistrar() Looks like the macro is expanding to find the ObservationRegistrar type inside the ComposableArchitecture module, and that is not correct. We want the registrar from the Observation framework.
— 6:16
This is happening because of this helper right here: static var qualifiedRegistrarTypeName: String { return "\(moduleName).\(registrarTypeName)" }
— 6:26
Here moduleName refers to our module, ComposableArchitecture, but really it should be Observation: static var qualifiedRegistrarTypeName: String { return "Observation.\(registrarTypeName)" }
— 6:30
Now when we re-run the test we will see a more correct expansion: private let _$observationRegistrar = Observation.ObservationRegistrar()
— 6:39
So, this is a good starting point for our macro, and really Swift’s open source code has taken care of the heavy lifting for us. Let’s give it a spin in the case studies app. I hope I can delete all of the boilerplate in Counter.State and replace it with a simple struct annotated with the @ObservableState macro: @ObservableState struct State: Equatable { var count = 0 var isDisplayingCount = true }
— 7:20
Well, unfortunately this does not compile: Type ‘Counter.State’ does not conform to protocol ‘ObservableState’
— 7:25
It seems that our Counter.State no longer conforms to the ObservableState protocol, and that’s because I deleted the _$id field from the type. That is a detail that should be mostly hidden from people, and so perhaps the macro should generate that field too.
— 7:39
In the implementation we can leverage some existing infrastructure in the macro’s code to add the _$id field if it is not already present in the type: declaration.addIfNeeded( "public let _$id = UUID()", to: &declarations )
— 8:03
And now when we re-run the macro test we will see the field was added to the expansion. We can even look at the failure message to see exactly what changed since the last time we recorded: failed - Automatically recorded a new snapshot for “expansion”. Difference: … + + public let _$id = UUID() } Re-run “testBasics()” to assert against the newly-recorded snapshot.
— 8:12
The case studies is not yet building, though, because we have to update the macro’s signature to let Swift know we are adding yet another member to structs annotated with the macro: @attached( member, names: named(_$observationRegistrar), named(access), named(withMutation), named(_$id) )
— 8:35
With that change let’s see if our case studies are in a better shape now. The project is compiling, so that is promising, and we can even expand the macro on Counter.State to see that everything looks very similar to what we had before, but we didn’t have to write any of it ourselves.
— 8:57
Also, for whatever reason, we are now able to delete the init we created a moment ago. The project still compiles somehow, and so this almost certainly points to there being a bug somewhere in the Swift compiler.
— 9:11
While we’re at it, let’s also update TwoCounters.State to use the new @ObservableState macro because it had even more boilerplate: @ObservableState struct State: Equatable { var counter1 = Counter.State() var counter2 = Counter.State() var isDisplayingSum = true }
— 9:38
And this too compiles. But does it work? Let’s run it in the preview to see that it does seem to work!
— 9:45
I can increment and decrement each of the counters individually, and the sum is updating.
— 9:53
I can turn the sum display off, and then the TwoCountersView no longer even re-computes its body. I can even turn off the counts in each of the counters, and then no view re-computes at all.
— 10:03
Then if I turn the sum display back on we will see that incrementing and decrementing the counters causes the sum display to update and the TwoCountersView to re-compute its body, yet neither of the counters needs updating.
— 10:07
And finally, reseting the counter works as expected, and does not break the UI. Making IfLetStore obsolete
— 10:14
And so this is pretty incredible. Thanks to the @ObservableState macro, and just a small amount of updated code in the core Composable Architecture library, we can now allow views to have full, unfettered access to state in the Store , but only that state will be observed. Changes to other pieces of state will not cause the views to re-compute their bodies.
— 10:31
So, this is all looking pretty great, and if we stopped right here to rest on our laurels, I think everyone would actually be pretty happy. We can build features in the Composable Architecture in a much simpler manner, and the minimal amount of state is observed in the view without doing any extra work. Brandon
— 10:45
But the really exciting thing about these new observation tools is that it allows us essentially challenge every single assumption we made when we first created the Composable Architecture. Over the last 3 and a half years, since the library’s first release, we have had to maintain a number of helper views for transforming stores in order to minimize the number of times the view would need to re-compute its body.
— 11:06
This includes things like the IfLetStore , SwitchStore , ForEachStore , NavigationStackStore , and even the various view modifiers that can drive sheets, popovers, covers and more from stores. Almost all of those helpers can be completely eliminated now that we have a way for the view to automatically observe only the changes to fields it actually accesses in the view.
— 11:28
Let’s take a look at how this is possible.
— 11:32
We are going to take a look at the “optional state” case study to get a feel for how we can start peel away at the layers in the Composable Architecture. It’s a very simple feature that simply houses another feature inside it, but held as an optional, and has a button for toggling to show and hide the child feature.
— 12:12
Nothing too impressive, but it does demonstrate a very important concept when modeling domains in applications. We often need to show and hide child features, and we want to be able to do so in a concise way, with the ability to integrate the parent and child domains together, and allowing the child domain to be completely isolated. And oh, also, it’d be nice if the child feature’s effects were automatically cancelled when it is dismissed.
— 12:39
And the Composable Architecture provides all the tools to make this very easy, though it is somewhat cumbersome in the view. The OptionalBasics reducer holds onto some optional Counter.State in its state: struct State: Equatable { var optionalCounter: Counter.State? }
— 13:00
As well as all of the Counter.Action s in its actions: enum Action: Equatable { case optionalCounter(Counter.Action) case toggleCounterButtonTapped }
— 13:09
Then in the actual implementation of the reducer we can make use of the ifLet operator to integrate the parent and child domains together: var body: some Reducer<State, Action> { Reduce { state, action in switch action { case .toggleCounterButtonTapped: state.optionalCounter = state.optionalCounter == nil ? Counter.State() : nil return .none case .optionalCounter: return .none } } .ifLet(\.optionalCounter, action: \.optionalCounter) { Counter() } }
— 13:44
And finally in the view we use the IfLetStore view in order to transform a store of an optional domain into a store of an honest, non-optional domain: IfLetStore( store.scope( state: \.optionalCounter, action: \.optionalCounter ) ) { store in Text(template: "Counter.State is non-nil") CounterView(store: store) .buttonStyle(.borderless) .frame(maxWidth: .infinity) } else: { Text(template: "Counter.State is nil") }
— 14:40
Note that we aren’t even using a WithViewStore in this feature because the view doesn’t directly need access to state. Instead the IfLetStore takes care of transforming a store of optional state into a store of honest state.
— 14:58
It does this by first scoping the store of parent domain down to just the optional counter domain, and the IfLetStore does extra work under the hood in order to only observe when the state flips from nil to non- nil or vice-versa: public var body: some View { WithViewStore( store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) }, content: content ) }
— 15:19
And when it detects the state flips from nil to non- nil it derives a new child store of honest, non-optional state, and hands that off to the content closure.
— 15:34
So, this is really powerful. This pattern of having an isolated child feature that can be presented and dismissed automatically is typically quite difficult to do in applications, but the Composable Architecture provides all the tools necessary to make this a simple matter of constructing a view and supplying the appropriate arguments.
— 15:57
But, what if we could massively simplify this by using a more standard kind of if let Swift syntax: if let store = store.scope( state: \.optionalCounter, action: \.optionalCounter ) { } else { }
— 16:21
That is, what if when scoping to an optional domain you would get back an optional store.
— 16:36
This syntax simply was not possible in a pre-Observation world. Or at least it was not possible to make the tool ergonomic. But, let’s try creating it real quick in the most naive way possible and see what goes wrong.
— 16:57
We are going to extend the Store type to provide a new kind of scope : extension Store { public func scope() { } }
— 17:04
This scoping operation will take two arguments: a key path to isolate a piece of child state from the parent, and a case key path to isolate the child actions from the parent: extension Store { public func scope<ChildState, ChildAction>( state: KeyPath<State, ChildState?>, action: CaseKeyPath<Action, ChildAction> ) { } }
— 17:25
This CaseKeyPath concept is something we introduced just a few episodes again when we revolutionized case paths with Swift macros. We found that it is possible to leverage key path syntax for constructing case paths, and a ton of benefits came with doing that.
— 18:01
And the final part of the signature is that we are going to return an optional Store of the child domain: extension Store { public func scope<ChildState, ChildAction>( state: KeyPath<State, ChildState?>, action: CaseKeyPath<Action, ChildAction> ) -> Store<ChildState, ChildAction>? { } }
— 18:17
So, when this method is invoked, if the child state is nil , then we can just return nil right away: guard let childState = self.state[keyPath: state] else { return nil }
— 18:49
However, we can’t access state because we haven’t constrained this scope to only work with observable state: Property ‘state’ requires that ‘State’ conform to ‘ObservableState’
— 19:00
This is an important decision for us to make. Should we only allow this scoping operation when dealing with ObservableState ? extension Store where State: ObservableState { … }
— 19:13
Or should we allow it to be used more broadly? Well, let’s approach this in the most naive way possible by not introducing any potentially unneeded constraints: extension Store { … }
— 19:22
Let’s see what goes wrong if we allow uninhibited access to this new operation. This means we’ll have to use the stateSubject to get the current state, which is the unobserved version of the state: guard let childState = self.stateSubject.value[keyPath: state] else { return nil }
— 19:40
And if we get past this guard it means we have non- nil state, and so we can procure a store scoped to the child domain. And we can even use an existing, internal scope on the store to do the heavy lifting for us: return self.scope( state: <#(ObservableState) -> ChildState#>, id: <#(ObservableState) -> AnyHashable#>, action: <#(ChildAction) -> Action#>, isInvalid: <#(ObservableState) -> Bool#>, removeDuplicates: <#(ChildState, ChildState) -> Bool#> )
— 20:02
Now this is a form of scope that most of our viewers have never seen before. In fact, we’ve never talked about it in episodes, and so if you do know this signature then it means you must be reading through the source code.
— 20:15
This scope has extra arguments that allow us to perform a few extra tricks inside the scope. For one thing, there’s an id closure that takes the parent state and returns any hashable value: id: <#(ObservableState) -> AnyHashable#>,
— 20:22
This id argument allows us to cache any stores created from a scope so that if we ever try scoping again with the same information we can skip creating a whole new one from scratch and just provide the one previously created.
— 20:40
The isInvalid argument allows us to say when a store has gotten into a bad state, at which point the store should essentially be turned “off”. This is necessary unfortunately because SwiftUI can hold onto views and their properties long after the view has been visually removed from the UI, and worse, SwiftUI can even write to bindings in those views, causing actions to be sent into stores that aren’t really hooked up to active features.
— 21:09
And finally the removeDuplicates argument allows us to skip certain internal work in the scope when we know at a higher level that nothing has actually changed in the system. It’s a small performance boost that we don’t really need to worry about right now.
— 21:24
And the state and action arguments are basically what you are already familiar with. They allow you to chisel away the parent state down to some child state, and describe how to embed child actions into the parent domain.
— 21:33
Well, let’s start filling in these arguments, one at a time. We might hope we could start with something simple, such as just plucking out the child state from the parent state like so: state: { $0[keyPath: state] }, …
— 21:45
But this isn’t quite right: Value of optional type ‘ChildState?’ must be unwrapped to a value of type ’ChildState’ At the end of the day we need to return a store of honest, non-optional state. Well, we do have a piece of such state unwrapped above, so we could coalesce it: state: { $0[keyPath: state] ?? childState }, …
— 22:09
And this compiles.
— 22:11
Next we have the id for caching the store, and thanks to the fact that key paths and case key paths are hashable, we can take both into consideration for the cache key: … id: { _ in [state, action] as [AnyHashable] }, …
— 22:31
The fact that we can use key paths as cache keys is kind of incredible. When you first learn that key paths are hashable you probably think it’s a be weird. What does it mean to take the hash value of a getter/setter combo, and check for the equality between two getters/setters? How could that ever be useful?
— 22:49
But here we see a concrete use of this super power. It allows us to cache stores so that they don’t have to be recreated later, and that comes with a performance benefit and the benefit to having a stable concept of “store” for a feature. And the Observation framework in Swift also uses this key path super power to accomplish all of its magic too.
— 23:15
Next we have the action closure: … action: { }, …
— 23:18
In this internal version of scope , the action argument is handed a child action and we need to embed it in the parent domain. This can be done by using our case key path: action: { action($0) },
— 23:35
Next we will invalidate the store once the child state goes nil : isInvalid: { $0[keyPath: state] == nil },
— 23:49
That way if SwiftUI ever tries writing to a binding after the child feature has been dismissed, we can appropriately ignore it.
— 23:54
And finally we are not going to worry about the removeDuplicates argument: removeDuplicates: nil
— 24:02
And that is all it takes. We now have an operation that can transform a Store of some parent domain into an optional Store of some child domain embedded inside the parent. And in fact, the case study is even compiling now, including that theoretical syntax we sketched out a moment ago: if let store = store.scope( state: \.optionalCounter, action: \.optionalCounter ) { }
— 24:21
And we can even create a view inside the if branch of this conditional: if let store = store.scope( state: \.optionalCounter, action: \.optionalCounter ) { Text(template: "Counter.State is non-nil") CounterView(store: store) .buttonStyle(.borderless) .frame(maxWidth: .infinity) }
— 24:33
And best of all, we can use a simple else conditional to provide the fallback view: if let store = store.scope( state: \.optionalCounter, action: \.optionalCounter ) { … } else { Text(template: "CounterState is nil") }
— 24:57
No need to understand that in an IfLetStore there’s an additional trailing closure argument for specifying the “then” view. We can just use simpler, more vanilla Swift constructs.
— 25:04
But, does it work?
— 25:06
Well, unfortunately no. Tapping the “Toggle counter state” button doesn’t do anything. And the problem is that nothing is observing the state changing from nil to non- nil . The IfLetStore was doing that for us under the hood, but now we have no observation mechanism whatsoever.
— 25:28
In a pre-Observation world we actually need to introduce some view state so that we can observe the optionality of the state: struct ViewState: Equatable { let isOptionalCounterNonNil: Bool init(state: OptionalBasics.State) { self.isOptionalCounterNonNil = state.optionalCounter != nil } }
— 26:03
Then wrap everything in WithViewStore : var body: some View { WithViewStore( store, observe: ViewState.init ) { viewStore in … } }
— 26:11
And then only in this WithViewStore closure would we be allowed to use the if let syntax on stores. And now the case study is working again.
— 26:26
But at this point the viewStore argument isn’t even needed at all. The whole point of the ViewState is simply to give the view a kick in the butt so that it re-computes its view, allowing store.scope to give us back a non-optional store.
— 26:39
And this is a huge gotcha. We must remember that anytime you use if let on a store that you additionally need to add a boolean to your view state so that you can properly observe when the state flips from nil to non- nil and vice-versa. There’s nothing letting you know that this is required, and if you forget you will just have a subtly broken UI.
— 26:59
And so this is why we never shipped this tool in a pre-Observation world. But now that we have such a powerful and lightweight way to observe specific pieces of state that a view accesses, we can finally and properly implement this feature.
— 27:19
We will restrict this scope operation to only work on observable state: extension Store where State: ObservableState { public func scope<ChildState, ChildAction>( state: KeyPath<State, ChildState?>, action: CaseKeyPath<Action, ChildAction> ) -> Store<ChildState, ChildAction>? { … } }
— 27:32
Which means we can now access the state property on the store: guard let childState = self.state[keyPath: state] else { return nil }
— 27:35
And now we get a compiler error in the case study letting us know that the scope operation is no longer available. That forces us to make the feature’s State observable: @Reducer struct OptionalBasics { @ObservableState struct State: Equatable { var optionalCounter: Counter.State? } … }
— 28:07
And now we can get rid of the ViewState .
— 28:11
And we can get rid of the WithViewStore .
— 28:17
And now the demo works again. We can toggle the optional state on and off, and when on we can interact with the counter by incrementing, decrementing, hiding the count and reseting. Everything is fully functional.
— 28:44
However, there is a problem lurking in the shadows. We are no longer observing the minimal amount of state. To see the problem, let’s add _printChanges to the view’s body: var body: some View { let _ = Self._printChanges() … }
— 28:58
Any interaction with the counter causes the OptionalBasicsView to re-compute its body. We can even toggle the count display off, and incrementing causes the OptionalBasicsView to re-compute its body even though the CounterView isn’t even computing its body. Ideally no view would be re-computing its body.
— 29:19
So, what is going on?
— 29:20
Well, let’s enable our symbolic breakpoints on the access and withMutation methods in the Observation framework to see what is going on. If we launch the app in the simulator and tap on the “Optional state” row, we instantly get caught in the access method. And it’s coming from OptionalBasics.State : get { access(keyPath: \.optionalCounter) return _optionalCounter }
— 29:34
And this access is being called from the optional scope we just added a moment ago. That means the act of trying to unwrap an optionally scoped store is causing the parent view to subscribe to changes to the optionalCounter state. And we do want to subscribe to some of that state’s changes. In particular, we want to know when the state flips from nil to non- nil , or vice-versa. Additionally we would like to know if the Counter.State ’s structural identity changes.
— 30:16
But we don’t want to be notified when just anything happens inside the counter state, such as the count incrementing. But if we continue execution and tap the “+” button, we will see we get caught in the withMutation breakpoint: set { withMutation(keyPath: \.optionalCounter) { _count = newValue } }
— 31:12
This means a change to the counter’s count is causing a withMutation to happen on a property that the parent view has claimed it wants to observe. And that is why it is re-rendering, even though it doesn’t actually care about the specific count at all. It only cares about the structural identity of the child feature’s state.
— 31:49
What we need to do is insert a bit of extra logic around this withMutation so that we can elide it when the identity of the state has not changed. This is very similar to what we are already doing in the store: 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 } } }
— 32:24
We just now need to do it for each property of an observable struct.
— 32:31
Luckily this is quite straightforward to do. We can simply modify the macro code so that the set accessor generated inserts a bit of extra logic for checking the observable identity of the new and old values to figure out if it can elide the withMutation : let setAccessor: AccessorDeclSyntax = """ set { if let old = _\(identifier) as? any ObservableState, let new = newValue as? any ObservableState, old._$id == new._$id { _\(identifier) = newValue } else { withMutation(keyPath: \\.\(identifier)) { _\(identifier) = newValue } } } """
— 34:23
Now when we run the preview we will see that everything works exactly as it did before, but we are no longer over-observing state. Toggling the counter on and then interacting with the counter does not cause the OptionalBasicsView to re-compute its body at all.
— 35:19
And this is pretty incredible. This is something that does not work in vanilla SwiftUI. If you hold an optional value type in your observable model, then the mere act of touching that optional value, even if you are only checking for its nil status and don’t care at all about the insides of the value, you will still unwittingly be subscribed to everything in the value.
— 35:47
Yet here we can access state without fear, even with value types, because we are able to properly track when the identity of the value changes. We seem to have achieved granular observation of value types in a SwiftUI application, and it’s all thanks to the Composable Architecture. This simply is not possible in just any vanilla SwiftUI application.
— 36:07
We can even do some really wild stuff. Let’s add another counter feature to this feature, but this time it will be non-optional: @ObservableState struct State: Equatable { var nonOptionalCounter = Counter.State() … } enum Action: Equatable { case nonOptionalCounter(Counter.Action) … }
— 36:26
And we’ll integrate the additional Counter reducer into the OptionalBasics reducer via Scope : var body: some Reducer<State, Action> { Scope( state: \.nonOptionalCounter, action: \.nonOptionalCounter ) { Counter() } Reduce { state, action in switch action { case .nonOptionalCounter: return .none … } } }
— 36:48
And we’ll display this new counter in a section of the view: Section { CounterView( store: store.scope( state: \.nonOptionalCounter, action: \.nonOptionalCounter ) ) .buttonStyle(.borderless) .frame(maxWidth: .infinity) }
— 37:16
Interacting with this new, non-optional counter does not make the parent view re-render, and so that’s great.
— 37:31
But then let’s also sum the two counters together, but because one of them is optional we will need to do some optional coalescing: Section { Text( ( (store.optionalCounter?.count ?? 0) + store.nonOptionalCounter.count ).description ) } header: { Text("Sum") }
— 38:04
Even with all of these bizarre ways we are composing features together and accessing child state from the parent, this view still observe the bare essentials necessary to do its job. The OptionalBasicsView will re-compute its body only if its non-optional counter changes, or if its optional counter’s count changes. But, for example, we can toggle the count display in the optional counter on and off, and none of that makes the OptionalBasicsView re-render, even though we are accessing a part of the optionalCounter directly from the parent view: store.optionalCounter?.count Next time: observing enums
— 39:40
So this is all looking absolutely incredible. We are finding that by having the view automatically subscribe to the minimal amount of state that it needs access to, we allow ourselves to greatly simplify the tools that the Composable Architecture provides. We no longer need to hide little efficiency tricks inside dedicated view helpers, like the IfLetStore , and instead we can just access the state directly, do a regular, vanilla Swift if let , and everything just works as we expect. This is going to have a huge impact on applications built with the Composable Architecture. Stephen
— 40:11
But we can take things further. There are more special view helpers in the library that exist only because we needed a way to minimally observe state. One such example is the SwitchStore , which allows you to model your domains with enums while still allowing you to split off child stores focused in on one particular case of the enum. It’s a powerful tool, but turns out it is completely unnecessary in a world of observation.
— 40:32
Let’s take a look…next time! Downloads Sample code 0261-observable-architecture-pt3 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 .