Video #262: Observable Architecture: Observing Enums
Episode: Video #262 Date: Dec 18, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep262-observable-architecture-observing-enums

Description
We’ve made structs and optionals observable in the Composable Architecture, eliminating the need for ViewStores and IfLetStores, so what about enums? If we can make enums observable, we could further eliminate the concept of the SwitchStore, greatly improving the ergonomics of working with enums in the library.
Video
Cloudflare Stream video ID: b00008836ae36360e92cd61f33f42583 Local file: video_262_observable-architecture-observing-enums.mp4 *(download with --video 262)*
Transcript
— 0:05
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
— 0:31
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.
— 0:53
Let’s take a look. Getting rid of SwitchStore
— 0:56
We have an integration test suite in the library with a whole bunch of very specialized demos that we use to make sure that the library behaves correctly in a variety of situations. There’s one in particular that deals with enum state and uses a SwitchStore , and so let’s hop over to EnumTestCase.swift .
— 1:11
We can run this demo in the preview to see something very, very basic. There are two buttons that are each related to a case of an enum. If we toggle one of the buttons on, the other automatically flips off. And we can flip both off.
— 1:19
When one of the buttons is on, a counter displays below, and we can interact with that counter by incrementing, decrementing, and we can even dismiss the counter. This is something provided by our presentation tools. Child features are capable of easily communicating up to the parent feature presenting it, and have that parent features dismiss the child.
— 1:49
We can also open the console to see a bunch of logs printed to the console while we were interacting with the preview. In the integration test suite many aspects of the internals of the library are instrumented, such as when stores are init ’d and deinit ’d, when scopes are called, and when view bodies are re-computed. The integration test suite actually makes assertions on these logs so that it can prove it knows exactly how the internals are behaving in a real life Composable Architecture application, but we will look at the tests a bit later.
— 2:20
For now let’s take a look at the code. It’s pretty straightforward, but not as straightforward as it could be. There’s a feature reducer that holds onto a single piece of destination state: struct Feature: Reducer { struct State: Equatable { @PresentationState var destination: Destination.State? } … }
— 2:28
This is following a pattern that we have discussed many times on Point-Free. We like to navigate all of the places a feature can navigate to with a single piece of optional enum state, and that gives us compile time guarantee that at most one destination can be active at a time.
— 2:43
This @PresentationState property wrapper helps facilitate some logic for this presentation, and the Destination reducer is defined below: @Reducer struct Destination { enum State: Equatable { case feature1(BasicsView.Feature.State) case feature2(BasicsView.Feature.State) } enum Action { case feature1(BasicsView.Feature.Action) case feature2(BasicsView.Feature.Action) } var body: some ReducerOf<Self> { Scope(state: \.feature1, action: \.feature1) { BasicsView.Feature() } Scope(state: \.feature2, action: \.feature2) { BasicsView.Feature() } } }
— 2:51
It simply integrates all of the different places we can navigate to into one package. There’s an enum for the state and an enum for the actions, and in the body we scope down to each case respectively to compose the reducers together.
— 3:15
Then, in the body of our main feature reducer, we have some core logic for populating and clearing out the destination state when various buttons are tapped, and we integrate all of the destinations into that core reducer using the ifLet operator: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .destination: return .none case .toggle1ButtonTapped: switch state.destination { case .feature1: state.destination = nil case .feature2: state.destination = .feature1( BasicsView.Feature.State() ) case .none: state.destination = .feature1( BasicsView.Feature.State() ) } return .none case .toggle2ButtonTapped: switch state.destination { case .feature1: state.destination = .feature2( BasicsView.Feature.State() ) case .feature2: state.destination = nil case .none: state.destination = .feature2( BasicsView.Feature.State() ) } return .none } } .ifLet(\.$destination, action: \.destination) { Destination() } }
— 3:41
The ifLet operator is the real powerhouse of these navigation tools. It runs the child feature’s logic when a child action comes in, and it takes care of automatically cancelling effects when the child feature is dismissed.
— 3:50
All of this reducer code is completely reasonable, and there’s not much that could be improved here. Perhaps some of the boilerplate could be automatically generated by a macro or something, but still at the end of the day this same code is powering the feature.
— 4:02
Where things can be massively improved is over in the view. Right off the bat we are using a WithViewStore view to observe some view state: WithViewStore( store, observe: ViewState.init ) { viewStore in Section { switch viewStore.tag { … } } }
— 4:16
This subset of state is defined nearby and is simply a wrapper around an enum tag for a particular case. struct ViewState: Equatable { enum Tag { case feature1, feature2, none } let tag: Tag init(state: Feature.State) { switch state.destination { case .feature1: self.tag = .feature1 case .feature2: self.tag = .feature2 case .none: self.tag = .none } } } The minimal view state we care about is the ability to switch over the cases of an enum, ignoring what associated values may be inside them.
— 4:32
And so that’s why we take the time to do the important, but laborious work, of modeling the enum tag in a dedicated ViewState type.
— 4:47
But if we naively observed all state like this: WithViewStore(store, observe: { $0 }) { viewStore in }
— 4:52
…in order to get access to the destination to switch on: switch viewStore.destination { case .feature1: case .feature2: case .none }
— 4:56
…then this would be a highly inefficient view. We are listening for all changes inside the destination even though the only thing we care about is the “tag” of the enum. That is, we only care if the value is in the feature1 case, the feature2 case or nil .
— 5:17
Ideally the Composable Architecture can now be smarter about these kinds of observation so that we don’t have to do any additional work, and can just access the state immediately from the store.
— 5:27
Wouldn’t it be great if we could completely get rid of the ViewState struct, and just switch on the store’s destination right away: Form { Section { switch store.destination { … } } }
— 5:50
And ideally this would observe only the bare minimal of state. That would be pretty amazing.
— 5:56
Further, down below we see more cruft that we would love to clean up: IfLetStore( store.scope( state: \.$destination, action: \.destination ) ) { store in SwitchStore(store) { … } }
— 6:04
First this has an IfLetStore which we already know can be simplified. That IfLetStore peels away the layer of optionality surrounding the destination , and so once we get a store focused in on the honest Destination domain, we make use of this SwitchStore view: SwitchStore(store) { switch $0 { case .feature1: CaseLet( \Feature.Destination.State.feature1, action: Feature.Destination.Action.feature1 ) { store in Section { BasicsView(store: store) } header: { Text("Feature 1") } } case .feature2: CaseLet( \Feature.Destination.State.feature2, action: Feature.Destination.Action.feature2 ) { store in Section { BasicsView(store: store) } header: { Text("Feature 2") } } } }
— 6:15
The SwitchStore , when used in conjunction with the CaseLet view, allows you to derive a store for each case of an enum, while observing the minimal amount of state in the process.
— 6:31
For example, deep inside the SwitchStore it performs this logic so that it only re-computes its view when it detects the tag of the enum changes: public var body: some View { WithViewStore( store, observe: { $0 }, removeDuplicates: { enumTag($0) == enumTag($1) } ) { viewStore in content(viewStore.state) .environmentObject( StoreObservableObject(store: store) ) } }
— 6:40
This is important work to be done, because it helps our views to be as efficient as possible, but also the tools are a bit cumbersome.
— 6:46
And worse, the CaseLet call site is not able to benefit from type inference or autocomplete: CaseLet( \Feature.Destination.State.feature1, action: Feature.Destination.Action.feature1 ) { store in … }
— 6:52
We have to spell out the full types here in order to tell the CaseLet how to extract out feature1 state from the destination and how to embed feature1 actions into the destination.
— 7:05
And the reason the full types need to be specified is because the SwitchStore and CaseLet employ a little bit of indirection in order for them to work properly. The SwitchStore puts the store that is focused on the enum domain into an environment object so that it can be implicitly passed to the CaseLet : .environmentObject( StoreObservableObject(store: store) )
— 7:22
And then the CaseLet view retrieves that store so that it can further scope onto it to chisel it down to the domain of a particular case: public struct CaseLet< EnumState, EnumAction, CaseState, CaseAction, Content: View >: View { … @EnvironmentObject private var store: StoreObservableObject< EnumState, EnumAction > … }
— 7:29
That little bit of implicit indirection completely breaks type inference, and that’s a bummer. It also introduces some safety problems in the tools because if you ever try to retrieve an environment object that has not been previously set you will get a runtime crash.
— 7:51
And not only are the tools verbose and have sharp edges, but they also comprise of hundreds of lines of library code that we have to maintain. Wouldn’t it be so much better if we could use simpler, more vanilla SwiftUI constructs in order to destructure this complex domain?
— 8:04
Well, first of all, as we’ve seen before we should be able to use a simple if let to unwrap the optional destination domain: if let store = store.scope( state: \.destination, action: \.destination ) { }
— 8:23
Then, in that store we could reach directly into the state in order to switch over all the possible destinations: switch store.state { case .feature1: case .feature2: }
— 8:40
And then once we detect we are in a particular case we can further scope on the destination store in order to extract out a child domain, and since that can fail we will need to if let to unwrap it: switch store.state { case .feature1: if let store = store.scope( state: \.feature1, action: \.feature1 ) { Section { BasicsView(store: store) } header: { Text("Feature 1") } } case .feature2: if let store = store.scope( state: \.feature2, action: \.feature2 ) { Section { BasicsView(store: store) } header: { Text("Feature 2") } } }
— 9:07
And this would be much, much simpler, and ideally it should still observe the minimal amount of state possible. This parent view should only re-compute its body when it detects the case of the destination enum changes, but it should not re-compute for any changes made inside each case of the enum. At least not unless it happens to reach inside one of those cases to access state in the view. Observable PresentationState
— 9:33
And this would be really great if we could make this syntax a reality. We would get rid of two entire view helpers in the library in exchange for simpler, more plain Swift statements. That makes this code look a lot more similar to what a vanilla SwiftUI feature would look like, and that would make the library much more approachable. Brandon
— 9:49
Yeah, that would be great, so let’s see what it takes to make this syntax a reality, and hopefully it all just works magically, with the most minimal amount of state observation possible.
— 10:01
We could start by trying to apply the @ObservableState macro to Feature.State , but before even doing that let’s go to the child feature, BasicsView.Feature and update it to use observation.
— 10:23
We will mark its State struct with the @ObservableState macro: @Reducer struct Feature { @ObservableState struct State: Equatable, Identifiable { … } … }
— 10:37
And we can remove the WithViewStore entirely: var body: some View { let _ = Logger.shared.log("\(Self.self).body") Text(store.count.description) Button("Decrement") { store.send(.decrementButtonTapped) } Button("Increment") { store.send(.incrementButtonTapped) } Button("Dismiss") { store.send(.dismissButtonTapped) } }
— 10:53
That’s all it takes and this view should work exactly as it did before.
— 10:57
Now let’s try updating the EnumTestCase.Feature reducer to use the new observation machinery. We are immediately met with a problem when applying the @ObservableState macro to Feature.State : @ObservableState struct State: Equatable { @PresentationState var destination: Destination.State? }
— 11:15
Because the macro does not play nicely with property wrappers: Property wrapper cannot be applied to a computed property
— 11:19
We’ve explained why this is the case in detail previously, and really the only thing we can do is ignore this field as far as observation goes: @ObservableState struct State: Equatable { @ObservationStateIgnored @PresentationState var destination: Destination.State? }
— 11:35
One could even argue that the @ObservableState macro should automatically ignore any fields that use @PresentationState . So let’s give it a shot.
— 11:48
We can hop over to the ObservableStateMacro , which defines the name of the ignored macro as a static: static let ignoredMacroName = "ObservationStateIgnored"
— 12:06
We can simply search the file for all uses of ignoredMacroName and check for @PresentationState , as well. // dont apply to ignored properties or properties // that are already flagged as tracked if property.hasMacroApplication( ObservableStateMacro.ignoredMacroName ) || property.hasMacroApplication( ObservableStateMacro.trackedMacroName ) || property.hasMacroApplication( "PresentationState" ) { return [] } … if property.hasMacroApplication( ObservableStateMacro.ignoredMacroName ) || property.hasMacroApplication( "PresentationState" ) { return [] } … if property.hasMacroApplication( ObservableStateMacro.ignoredMacroName ) || property.hasMacroApplication( ObservableStateMacro.trackedMacroName ) || property.hasMacroApplication( "PresentationState" ) { return [] }
— 13:14
That’s all it takes, so let’s write a macro test to make sure it works the way we expect. func testPresentationState() { assertMacro { """ @ObservableState struct State { @PresentationState var destination: Child.State? } """ } }
— 13:53
When we run the test, it’ll automatically inline a snapshot of the macro expansion: func testPresentationState() { assertMacro { """ @ObservableState struct State { @PresentationState var destination: Child.State? } """ } expansion: { struct State { @PresentationState var destination: Child.State? public let _$id = UUID() private let _$observationRegistrar = Observation.ObservationRegistrar() … } } }
— 14:03
And it works! The macro ignored the presentation state property entirely.
— 14:24
So let’s see if we can make use of this improvement. We can hop back over to the enum test case and remove the @ObservationStateIgnored : @ObservableState struct State: Equatable { // @ObservationStateIgnored @PresentationState var destination: Destination.State? }
— 14:42
And everything still compiles.
— 14:48
Now it certainly doesn’t seem correct to completely ignore observation for destination . We do want to observe some things inside that value, just not everything. But let’s continue in this naive way to see what kinds of problems arise.
— 15:08
With the macro applied we now get unfettered access to the store’s state in the view, which means we no longer need the ViewState struct, and some of the code we sketched earlier is now compiling, specifically where we switch on store.destination directly: Section { switch store.destination { … } }
— 15:31
The code we wrote to replace the SwitchStore is not compiling yet, though. While the if let store wrapping it does compile, let’s temporarily bring back the old style: if let store = store.scope( state: \.destination, action: \.destination ) { SwitchStore(store) { … } }
— 16:06
And this compiles too, but does it work?
— 16:11
Well, unfortunately not at all. Tapping either of the toggle buttons does not do anything in the UI. We can even add _printChanges to our reducer: @State var store = Store(initialState: Feature.State()) { Feature()._printChanges() }
— 16:36
To see that indeed the state is being mutated when we tap the button: received action: EnumView.Feature.Action.toggle1ButtonTapped EnumView.Feature.State( - _destination: nil, + _destination: .feature1( + BasicsView.Feature.State( + id: UUID(6499108A-D485-45D9-80DC-9FBC54D5C59E), + _count: 0 + ) + ) )
— 16:43
Yet the view is not updating. Even the button labels aren’t updating.
— 16:47
Well, this shouldn’t be too surprising, after all we told the observation framework to completely ignore the destination field. So it seems like that was far too simplistic, and we do need some notion of observing @PresentationState .
— 17:13
We can’t simply apply the @ObservableState macro to the PresentationState type and call it a day. The presentation state type is quite complex and has a lot of custom logic on the inside. We need to be more delicate with how we notify a registrar of changes that happen inside.
— 17:50
We can start by importing the Observation framework: import Observation
— 17:57
And we’ll add an observation registrar to the PresentationState type: private let _$observationRegistrar = ObservationRegistrar()
— 18:04
Currently we have some custom copy-on-write logic implemented in this type: public var wrappedValue: State? { get { self.storage.state } set { if !isKnownUniquelyReferenced(&self.storage) { self.storage = Storage(state: newValue) } else { self.storage.state = newValue } } }
— 18:37
…because secretly under the hood we hold the state inside a reference type. We do this to help alleviate some pressure on the stack for large, highly composed feature.
— 18:52
We need to update this so that we can notify the registrar when a mutation happens to the PresentationState that fundamentally changes the structural identity of the underlying state.
— 19:03
We are going to want to inspect the before and after of the mutation so that we can detect when structural identity changes, so in the set accessor the first thing we want to check is if the new and old value are castable to ObservableState : set { if let old = self.storage.state as? any ObservableState, let new = newValue as? any ObservableState { … } }
— 19:30
If they are, and their IDs are equal, then we get to skip withMutation entirely and just make the mutation right away with the existing logic: set { if let old = self.storage.state as? any ObservableState, let new = newValue as? any ObservableState, old._$id == new._$id { if !isKnownUniquelyReferenced(&self.storage) { self.storage = Storage(state: newValue) } else { self.storage.state = newValue } } }
— 19:47
And then if either the new or old value are not ObservableState , or if their IDs are not equal, then we will perform the mutation wrapped in withMutation : } else { self._$observationRegistrar.withMutation( of: self, keyPath: \.wrappedValue ) { if !isKnownUniquelyReferenced(&self.storage) { self.storage = Storage(state: newValue) } else { self.storage.state = newValue } } }
— 20:13
But, in order for us to be allowed to invoke withMutation we must conform to the Observable macro, so let’s do that: extension PresentationState: Observable {}
— 20:22
That’s all it takes for set , and then in get we do need to make sure to let the registrar know we are accessing the value: get { self._$observationRegistrar.access( self, keyPath: \.wrappedValue ) return self.storage.state }
— 20:41
And that is a very barebones integration of PresentationState with the new observation tools. But, if we run the EnumView in the preview we will see that as soon as we try toggling a feature on we get a crash. That doesn’t seem good.
— 20:58
This is happening due to that bit of implicit indirection happening inside SwitchStore and CaseLet , and is just yet another reason why those tools are not ideal. We have accidentally handed a store of the wrong shape to SwitchStore , and then that store is put into the environment, and when the CaseLet tries to retrieve it, it fails. And unfortunately the compiler does not have our back on this because our indirection work is completely circumventing the type system.
— 21:29
If we option-click the store in the if let we will see its full type: let store: Store< EnumView.Feature.Destination.State, PresentationAction<EnumView.Feature.Destination.Action> >
— 21:36
And we can now see that its action is focused in on the PresentationAction domain, whereas we want the store to be fully focused in on just the Destination domain.
— 22:13
Well, thanks to the fact that case paths are now actually modeled on the back of key paths, we can use simple dot-chaining syntax to further compose into the presented case of the destination case to fully get down to just the destination domain: if let store = store.scope( state: \.destination, action: \.destination.presented ) { … }
— 22:39
Now the preview no longer crashes, and everything does seem to work as you would expect. Observable enums
— 23:07
So, we have now made our theoretical syntax a reality. We ditched the dedicated and complicated SwitchStore and CaseLet views, and in its place used a far simpler tool: just a plain Swift switch statement and a plain Swift if let statement. Stephen
— 23:22
However, there is a problem with this. We are unfortunately over-observing state. And this is happening because there is an enum at the core of this feature that is deciding which destination is active and being presented on screen. And that enum has not been integrated into the observation machinery at all. But also, what does it even mean for an enum to be observable?
— 23:41
Well, let’s dig in a bit deeper.
— 23:45
If we run the preview, and filter the logs for “view.body” to get rid of all the ancillary stuff we don’t care about, then we will see that each interaction in the counter causes the EnumView ’s body to re-compute.
— 24:11
And if we run the app in the simulator, toggle the first counter on, then enable the withMutation symbolic breakpoint again, we will see that we get caught in the set accessor of PresentationState when we increment the counter: set { if let old = self.storage.state as? any ObservableState, let new = newValue as? any ObservableState, old._$id == new._$id { … } else { self._$observationRegistrar.withMutation( of: self, keyPath: \.wrappedValue ) { … } } }
— 24:50
We were not able to elide this withMutation based on the structural identity of the state inside PresentationState , and so we had no choice but to re-render the EnumView again.
— 25:04
Why is this happening? Well, it’s this line right here: switch store.destination { … }
— 25:15
It’s great that we can reach directly into the store to access the state, but the Destination.State is not observable at all: @Reducer struct Destination { enum State: Equatable { … } … }
— 25:24
And observation can only be as granular as the @ObservableMacro is applied.
— 25:30
So, somehow we need to make Destination.State observable: @Reducer struct Destination { @ObservableState enum State: Equatable { … } … }
— 25:35
And this is starting to seem really bizarre. It’s already bizarre that we are trying to make structs observable, but now we are trying to make enums observable?!
— 25:41
And in fact, the macro currently prohibits applying to an enum: ‘@Observable’ cannot be applied to enumeration type ’State’
— 25:44
…because what would it even mean for an enum to be observable?
— 25:47
Well, we can actually make sense of this. Enums do not have stored properties with getters and setters. At best, they can have computed properties, but those properties have no choice but to just switch over the enum and access or mutate state in one of the cases.
— 26:01
That means if the associated data inside each case of an enum is observable, then the enum kind of already becomes observable itself. Any mutation to the enum will be automatically registered with one of the registrars held in a case.
— 26:16
So, since enums seems to be as observable as their cases are, let’s see what it takes to literally conform the enum to the ObservableState protocol: enum State: Equatable, ObservableState { … }
— 26:28
To do this we need to provide the one single requirement, which is the _$id property: enum State: Equatable, ObservableState { var _$id: UUID … }
— 26:37
And this has to be computed since enums can’t have stored properties, and that gives us the ability to switch on self : enum State: Equatable, ObservableState { var _$id: UUID { switch self { case .feature1(_): <#code#> case .feature2(_): <#code#> } } … }
— 26:49
And right now we happen to be lucky in that each case holds onto observable state, and hence we can just take the ID from each case: var _$id: UUID { switch self { case let .feature1(state): return state._$id case let .feature2(state): return state._$id } }
— 27:04
This of course won’t always be true, but since it works in this case let’s just roll with it and see what happens.
— 27:10
And amazingly, this fixes our over-observation problem. We can now increment and decrement inside the counter and it does not make the EnumView re-compute its body. The only time it does re-compute is when toggling a counter on or off.
— 27:47
And now that the destination state is observable we get an opportunity to completely remove the SwitchStore and CaseLet s in favor of simpler, more vanilla Swift code we sketched earlier: if let store = store.scope( state: \.destination, action: \.destination.presented ) { switch store.state { case .feature1: if let store = store.scope( state: \.feature1, action: \.feature1 ) { … } case .feature2: if let store = store.scope( state: \.feature2, action: \.feature2 ) { … } } }
— 28:07
This all compiles, and the preview works exactly as it did before, and we are observing the minimal amount of state. The EnumView re-computes its body only when we show or hide a counter, but never when we increment or decrement the counter.
— 28:21
This is looking fantastic. We know longer have to know about the SwitchStore or CaseLet views, we can simply switch over the store’s state directly, scope them directly and benefit from autocomplete and type inference. We are now using simple, vanilla Swift concepts and have radically simplified the implementation of this view.
— 28:45
And because we can directly switch over state we get compile-time exhaustivity for free. If we comment out a case, we immediately get a compiler error that forces us to handle it. @ObservableState for enums
— 29:09
This is absolutely incredible. Not only do we get to remove entire concepts from the library, such a SwitchStore and CaseLet , but doing so massively improves the expressiveness of this code. We can now use simpler Swift constructs, such as plain switch and if let , and we get exhaustivity for free, and there’s no type indirection shenanigans happening that could hurts type inference or cause our app to crash at runtime.
— 29:32
And it all hinges on this seemingly bizarre concept of an observable enum, but we’ve seen that supporting observable enums is quite straightforward. Really we are just deferring the observability to each case, and that seems to work really well. Brandon
— 29:47
But the custom conformance we had to write was kind of a pain. We are not going to want to write that kind of code every time we have an enum in state, and so it would be nice to extend our @ObservableState macro to support enums.
— 30:02
Recall that we had to manually implement this _$id computed property in order to make our State enum conform to the ObservableState protocol: enum State: Equatable, ObservableState { … var _$id: UUID { switch self { case let .feature1(state): return state._$id case let .feature2(state): return state._$id } } }
— 30:11
Ideally we could use the @ObservableState macro to write this for us. @ObservableState enum State: Equatable { … // var _$id: UUID { // switch self { // case let .feature1(state): // return state._$id // case let .feature2(state): // return state._$id // } // } } ‘@Observable’ cannot be applied to enumeration type ‘State’
— 30:21
But this does not yet work. Our macro still explicitly prohibits enums.
— 30:28
If we hop over to the ObservableStateMacro implementation, then the place we will want to add this functionality is where we conform to the MemberMacro : extension ObservableStateMacro: MemberMacro { public static func expansion< Declaration: DeclGroupSyntax, Context: MacroExpansionContext >( of node: AttributeSyntax, providingMembersOf declaration: Declaration, in context: Context ) throws -> [DeclSyntax] { … } } This is what will allow us to add the _$id member to macros.
— 30:34
Now currently we early out by throwing an error when we detect an enum: if declaration.isEnum { // enumerations cannot store properties throw DiagnosticsError( syntax: node, message: """ '@Observable' cannot be applied to enumeration type \ '\(observableType.text)' """, id: .invalidApplication ) }
— 30:41
We don’t want to throw an error anymore. We want to do actual work in here.
— 30:46
To give ourselves some breathing room, let’s call out to a dedicated method for performing the enum-specific expansion: if declaration.isEnum { return try enumExpansion( of: node, providingMembersOf: declaration, in: context ) }
— 31:27
And we can get a stub of this into place: extension ObservableStateMacro { public static func enumExpansion< Declaration: DeclGroupSyntax, Context: MacroExpansionContext >( of node: AttributeSyntax, providingMembersOf declaration: Declaration, in context: Context ) throws -> [DeclSyntax] { return [] } }
— 31:55
In the array we return we want to construct a computed property for the _$id value: return [ """ var _$id: UUID { } """ ]
— 32:18
And the first thing to do in that property is to switch over self so we can handle each case individually: return [ """ var _$id: UUID { switch self { } } """ ]
— 32:29
Now we need to figure out how to iterate over each case of the element so that can create a case let for each one.
— 32:38
Remember that perhaps the simplest way to figure out how to traverse into a SwiftSyntax tree is to just get a test in place that exercises the code path and then put a breakpoint in the code. That will give you the ability to print out the syntax tree from lldb and figure out how you can traverse through all of its layers.
— 32:55
Let’s put a breakpoint on the return line: return [ … ]
— 32:58
And let’s get a basic test into place so that we get caught on the breakpoint: func testEnum() { assertMacro { """ @ObservableState enum State { case feature1(Feature1.State) case feature2(Feature2.State) } """ } }
— 33:21
We get caught on the breakpoint, and we can print out the syntax tree: (lldb) po declaration
— 33:33
…to see that all the information is there for us. We just have to extract it out.
— 33:36
We’ve even already done this twice in previous episodes. First in our episodes on testing macros and then our episodes on case path macros . We aren’t going to spell out the details for a third time, so if you are curious you can watch those episodes, but suffice it to say that we can go through the declaration , through the memberBlock , then the members , then flatMap on that in order to collect all the elements of each member: let enumCaseDecls = declaration.memberBlock.members .flatMap { $0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [] } Now we have an array of cases from the enum.
— 34:49
Further we can map on that to turn it into an array of case let statements: let caseLets = enumCaseDecls .map { enumCaseDecl in """ case let .\(enumCaseDecl.name.text)(state): return state._$id """ }
— 35:31
And then we can join all of these case let s with a newline to put inside the switch : return [ """ var _$id: UUID { switch self { \(raw: caseLets.joined(separator: "\n")) } } """ ]
— 35:40
Our macro test suite is still in record mode, so let’s run the test again to see the fresh macro expansion: func testEnum() { assertMacro { """ @ObservableState enum State { case feature1(Feature1.State) case feature2(Feature2.State) } """ } expansion: { """ enum State { case feature1(Feature1.State) case feature2(Feature2.State) var _$id: UUID { switch self { case let .feature1(state): return state._$id case let .feature2(state): return state._$id } } } """ } }
— 36:08
That looks pretty good to me. We are of course making a huge assumption that each case of the enum holds onto ObservableState , which may not always be the case. But we are OK making that simplifying assumption right now. The final version of the @ObservableState macro will of course be a lot more forgiving in the kinds of enums it can be applied to.
— 36:30
Now we can hop back over to our EnumView feature, and clean up the Destination.State enum by applying the @ObservableState macro: @Reducer struct Destination { @ObservableState enum State: Equatable { case feature1(BasicsView.Feature.State) case feature2(BasicsView.Feature.State) } … }
— 36:38
This compiles, and the preview works just as it did before, but we have massively cleaned up this code.
— 37:18
There’s another integration test that makes use of @PresentationState , and so I think it would be cool to see how it could be simplified using these new tools we just built. It’s called PresentationView , and we can run it in the preview.
— 37:48
Further, while the sheet is presented, you can tap a button to force the parent view to start displaying state that exists in the child view. This shows that the parent view can dynamically start observing state changes in the child, and we would like it to do so in the most minimal way possible.
— 38:32
The way it accomplishes this right now is through the maintenance of some ViewState that determines if the parent is observing the child count or not: struct ViewState: Equatable { var sheetCount: Int? init(state: Feature.State) { self.sheetCount = state.isObservingChildCount ? state.sheet?.count : nil } }
— 38:50
And then down in the view it observes that view state using WithViewStore : WithViewStore( store, observe: ViewState.init ) { viewStore in let _ = Logger.shared.log("\(Self.self).body") if let count = viewStore.sheetCount { Text("Count: \(count)") } }
— 39:12
All-in-all, this is quite cumbersome, and so it would be nice if we could just access state directly in the store however we want, and the observation tools of Swift would figure out what should be observed and what shouldn’t.
— 39:26
Well, this is possible, and it’s quite easy. We can mark the feature’s state with the @ObservableState macro: @ObservableState struct State: Equatable { var isObservingChildCount = false @PresentationState var destination: Destination.State? @PresentationState var sheet: BasicsView.Feature.State? }
— 39:48
Further we can mark the Destination.State enum with the @ObservableState macro: @Reducer struct Destination { @ObservableState enum State: Equatable { case fullScreenCover(BasicsView.Feature.State) case popover(BasicsView.Feature.State) } … }
— 40:10
And now the view is free to access any state it wants from the store , so we can first check if we are observing the child count, and if so unwrap that count value: let _ = Logger.shared.log("\(Self.self).body") if store.isObservingChildCount, let count = store.sheet?.count { Text("Count: \(count)") } // WithViewStore( // store, observe: ViewState.init // ) { viewStore in // let _ = Logger.shared.log("\(Self.self).body") // if let count = viewStore.sheetCount { // Text("Count: \(count)") // } // }
— 40:50
What’s really cool about this is that chaining if clauses in Swift is lazy, so if the first condition fails, the second condition will never be evaluated. And that means as far as the view is concerned, it never accessed sheet?.count , and so won’t observe it. And now that we aren’t futzing around with view stores, we can completely get rid of the ViewState : // struct ViewState: Equatable { // var sheetCount: Int? // init(state: Feature.State) { // self.sheetCount = state.isObservingChildCount // ? state.sheet?.count // : nil // } // }
— 41:13
And with that the preview works exactly the same, and the views re-compute their bodies in the most minimal way. We can see this by adding _printChanges to the view: var body: some View { let _ = Self._printChanges() … }
— 41:51
And see that any changes we make in one of the presented child views never causes the parent view to re-render, unless the parent view is specifically observing that bit of child state. Next time: observing collections
— 42:53
Thanks to the new Observation tools in Swift 5.9 we have now gotten rid of 4 specialized view helpers that previously existed only to aid in minimizing view re-computation. They are WithViewStore , IfLetStore , SwitchStore and CaseLet . We now get to use simpler, more vanilla Swift code to constructs these views, and they still minimally observe only the state that is touched in the view. Stephen
— 43:18
But there’s another really powerful and popular view helper that ships with the library that allows you to decompose complex list-based features into smaller units, and that’s the ForEachStore . With it you can easily transform a store of some collection of features into individual stores for each element in the collection. This allows you to have a dedicated, isolated Composable Architecture feature for each row in a list.
— 43:40
It would be amazing if we could get rid of this concept too, and just use a vanilla SwiftUI ForEach view. Well, this is absolutely possible, and it greatly simplifies the way one deals with lists in the Composable Architecture.
— 43:52
Let’s take a look…next time! Downloads Sample code 0262-observable-architecture-pt4 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 .