Video #234: Composable Stacks: Action Ergonomics
Episode: Video #234 Date: May 8, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep234-composable-stacks-action-ergonomics

Description
We begin designing brand new navigation stack tools for the Composable Architecture to solve all of the problems we encountered when shoehorning stack navigation into the existing tools, and more.
Video
Cloudflare Stream video ID: 87285b7015339ab024975a846d78d3d5 Local file: video_234_composable-stacks-action-ergonomics.mp4 *(download with --video 234)*
References
- Discussions
- Composable navigation beta GitHub discussion
- 0234-composable-navigation-pt13
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So, we can clearly see that trying to make use of navigation stacks using only the tools that the Composable Architecture gives you access to today is not going to cut it. There is a lot of verbose and ugly code that needs to be written, and there is a lot of room to make things safer and more concise. Brandon
— 0:20
Let’s start building the API of our dreams for the navigation stack. We will be able to take a lot of inspiration from how we approached this problem for PresentationState and PresentationAction , which worked great for tree-based navigation, but we are also going to have to wade into uncharted territory to extend the ideas to stacks.
— 0:39
So let’s get started. Defining NavigationStackStore
— 0:41
You just sketched out a really great theoretical syntax for a new NavigationStackStore that allows one to specify the path that powers the stack, a view that acts as the root of the stack, as well as all of the destination views that can be drilled down to: NavigationStackStore( self.store.scope( state: \.path, action: RootFeature.Action.path ) ) { Button("Go to counter") { viewStore.send(.goToCounterButtonTapped) } } destination: { switch $0 { case .counter: CaseLet( state: /RootFeature.Path.State.counter, action: RootFeature.Path.Action.counter, then: CounterView.init(store:) ) case .numberFact: CaseLet( state: /RootFeature.Path.State.numberFact, action: RootFeature.Path.Action.numberFact, then: NumberFactView.init(store:) ) } }
— 1:02
Let’s make this syntax a reality.
— 1:10
We’ll start by getting a stub of a view into place for NavigationStackStore : struct NavigationStackStore: View { var body: some View { NavigationStack { } } }
— 1:22
This type is gonna have lots of generics and arguments, but let’s just take it one step at a time.
— 1:28
To begin with, we know that NavigationStack s have a root content view, which is the thing provided by the first trailing closure. So, let’s add that: struct NavigationStackStore<Root: View>: View { let root: Root var body: some View { NavigationStack { self.root } } }
— 1:48
OK, making some progress now.
— 1:50
Next we know we want a second trailing closure, which SwiftUI’s regular NavigationStack does not have, and its purpose is to give us an immediate place to describe all of the navigation destination views that can be pushed onto the stack.
— 2:07
It will be a closure that is handed the initial state that was pushed onto the stack to cause the drill down, and that will be the thing we can switch on in order to provide a view for each case: let destination: (PathState) -> Destination
— 2:21
So, we need to add some more generics to NavigationStackStore : struct NavigationStackStore< PathState, Destination: View, Root: View >: View { … }
— 2:26
Further, we can go ahead and tack on the .navigationDestination view modifier directly to the content since we now know we will be listening for whenever PathState is added or removed from the collection that drives the navigation stack: .navigationDestination(for: PathState.self) { state in self.destination(state) }
— 2:52
However, this now forces us to make PathState hashable, so let’s do that: struct NavigationStackStore< PathState: Hashable, Destination: View, Root: View >: View { … }
— 3:03
And finally the NavigationStackStore also needs to accept a Store that is focused on the domain of the full collection of path elements, such as the identified array. I’m not sure what to do about the actions yet, so I will just use Never for that: struct NavigationStackStore< PathState: Hashable, Destination: View, Root: View >: View { let store: Store<IdentifiedArrayOf<PathState>, Never> … }
— 3:31
And this now means that the PathState must also be Identifiable in addition to Hashable . struct NavigationStackStore< PathState: Hashable & Identifiable, Destination: View, Root: View >: View { … }
— 3:40
Now, it’s clear the Store should be focused in on an IdentifiedArray of state since that collection is what powers the pushing and popping of features, but what should we use for actions?
— 3:55
If we look at the ad hoc work we performed earlier, we will see that we need access to a setPath action in order to construct a binding to hand to the NavigationStack : path: viewStore.binding( get: \.path, send: RootFeature.Action.setPath )
— 4:10
And further we need access to the indexed .path action for scoping down to the domain of a particular feature in the stack: action: { .path(id: counterState.id, action: .counter($0)) }
— 4:21
So we will abstractly need access to those kinds of actions, and this is reminiscent of what we did for tree-based navigation when we created the PresentationAction enum: enum PresentationAction<Action> { case dismiss case presented(Action) }
— 4:49
It abstractly represents the two most fundamental things you can do in a presented feature: you can either dismiss it, or you can do something in the child domain.
— 4:59
You might even wonder whether we could even reuse PresentationAction for stacks. That would be really cool, but unfortunately that does not work. In tree-based navigation all you can do is tell a particular feature to dismiss, for example when you swipe down on a sheet or tap “Cancel” on an alert.
— 5:14
But stack-based navigation has a few more possibilities. First, you can dismiss any child in the entire stack. In fact, we can see this very concretely if we run our demo in the simulator again, push on a whole bunch of counter features, and then tap and hold the “Back” button. You will see a little popup appear that shows the title of every single screen pushed onto the stack. We can select any one of these titles in order to pop the stack all the way back to that screen.
— 5:52
And further, the NavigationStack can append values to the array by writing directly to the binding. This is something that has no equivalent in sheets, popovers, alerts, etc. It’s always on us to manually construct the state to hand to SwiftUI. SwiftUI can’t do that on its own.
— 6:10
So, the PresentationAction enum is just too simple for the needs of stacks. We need something more high-powered.
— 6:17
So, we are going to create our own version of PresentationAction that is tuned specifically for stacks, and we will call it StackAction : enum StackAction<Action> { }
— 6:32
We will have a case for when a particular element of the stack sends an action, which will be identified by some hashable data: enum StackAction<Action> { case element(id: AnyHashable, action: Action) }
— 6:44
And then we need a case that represents the fundamental operation of what a stack can do. Over in the RootView we saw that fundamental operation was .setPath since that’s what the binding needs to do: enum StackAction<Action> { … case setPath(<#???#>) }
— 6:47
Having a single action for wholesale replacing the entire path of the stack is certainly one way to support both pushing and popping operations. It’s maybe a little heavy handed, since if you just want to push a single element onto the collection you have to send the entire collection in the action, but it definitely gets the job done for now.
— 7:24
However, what is this case supposed to hold onto?
— 7:28
It certainly should at least be an identified array, since that is what we are using over in our ad hoc version: enum StackAction<Action> { … case setPath(IdentifiedArrayOf<<#???#>>) }
— 7:38
…but an identified array of what?
— 7:40
Right now StackAction is only generic over the action domain and has no knowledge of state. So, this would force us to make StackAction further generic over state: enum StackAction<State: Identifiable, Action> { … case setPath(IdentifiedArrayOf<State>) }
— 7:57
And at this point we can simplify the element case a bit by using the
ID 8:04
So, this is a little different from PresentationAction in that it needs to be generic over State and Action , as opposed to just Action , but let’s go with it to see how things play out.
ID 8:14
With that defined, this is now the type of actions our NavigationStackStore is going to expect because it’s the bare minimum of what it needs to do its job: let store: Store< IdentifiedArrayOf<PathState>, StackAction<PathState, PathAction> >
ID 8:28
And we now have yet another generic to add to the type: struct NavigationStackStore< PathState: Hashable & Identifiable, PathAction, Destination: View, Root: View >: View { … }
ID 8:31
This view is very much turning into generics soup, but if we can pull it off it will be super powerful and the user of the library will never have to think about these generics.
ID 8:41
Now we can start implementing the real guts of this view. We want to construct a binding to the path so that it can be passed to the initializer of the NavigationStackStore : NavigationStack(path: <#Binding<_>#>) { … }
ID 8:55
But, in order to do that we need to construct a view store so that we can observe changes to the path and derive bindings. So, let’s wrap everything in a WithViewStore : WithViewStore( self.store, observe: <#(State) -> _#> ) { viewStore in }
ID 9:09
Now, we need access to the full identified array in order to derive a binding to it for the NavigationStack , so I guess that forces us to observe everything: WithViewStore(self.store, observe: { $0 }) { viewStore in … }
ID 9:21
That doesn’t seem ideal since the only thing the NavigationStack really cares about is when items are added or removed to the identified array. We can make this a lot more efficient by observing all of the state, but removing duplicates based on a closure that allows us to compare two values: WithViewStore( self.store, observe: { $0 }, removeDuplicates: <#(_, _) -> Bool#> ) { viewStore in … }
ID 9:47
Whenever the state changes and this closure evaluates to true, we will skip re-computing the body, and so that can help this view be more efficient.
ID 10:02
But, what should we should in here? Perhaps naively we could just compare the lengths of the array before and after: removeDuplicates: { $0.count == $1.count }
ID 10:14
That would certainly cover when a value is added to the array, or a value removed, but it doesn’t cover all situations. For example, it is theoretically possible to simultaneously add a value and remove a different value from the array, and this simplistic logic would be none the wiser. That would prevent the NavigationStack from properly animating that change.
ID 10:45
So we need something better here.
ID 10:47
Well, right now we are forcing that any state we added to the stack be Identifiable since it is being stored in an IdentifiedArray . The ID of a piece of state is the defining characteristic of the state, and so it’s not necessary to check for equality on the entire data type, which can be quite big. What if each feature in the stack has lots of data and destinations. We are going to check for equality on all of that data.
ID 11:08
We only need to check if the IDs of the before and after are the same: removeDuplicates: { $0.ids == $1.ids }
ID 11:22
This will be much more efficient. We are now only checking if a simple ordered set of IDs are equal, and this set will typically be quite small. On average, what is the maximum number of screens do you think get pushed onto the stack in your application? 4? Maybe 5? Certainly less than 10 right? At that point it’s very difficult to even keep track of where you are in the stack.
ID 12:12
OK, we are now observing all of the path state, but doing so in a way that the body of the view executes only if something fundamental changes about the path, and so we are finally ready to construct a binding for the NavigationStack : NavigationStack( path: viewStore.binding( get: { $0 }, send: { .setPath($0) } ) ) { … }
ID 12:51
We’re getting closer, but we now have the navigationDestination to deal with, which currently is just passing things along to the destination closure: self.root .navigationDestination(for: PathState.self) { pathState in self.destination(pathState) }
ID 13:04
This compiles, but it is unfortunately wrong.
ID 13:07
Remember that this destination closure is where we theorized the syntax of being able to do a simple switch statement with a bunch of CaseLet views that further destructure our path state enum into all the cases of features we can navigate to: } destination: { switch $0 { case .counter: CaseLet( state: /RootFeature.Path.State.counter, action: RootFeature.Path.Action.counter, then: CounterView.init(store:) ) case .numberFact: CaseLet( state: /RootFeature.Path.State.numberFact, action: RootFeature.Path.Action.numberFact, then: NumberFactView.init(store:) ) } }
ID 13:23
These CaseLet views can only be used inside a surrounding SwitchStore , and that is enforced at runtime because CaseLet views need access to an environment object that only SwitchStore s can provide. So this would crash at runtime if we were to fix all the other compiler errors and run this in the simulator.
ID 13:55
What we need to do is somehow surround the destination with a SwitchStore : self.content .navigationDestination(for: PathState.self) { state in SwitchStore(<#???#>) { state in self.destination(state) } }
ID 14:11
But what are we switching on?
ID 14:13
The current store we have is focused in on the full identified array of state, but we want to switch on the store that holds onto the domain of the feature just pushed onto the stack, which is represented by state . So, we need to further scope our store to get into just that domain: SwitchStore( self.store.scope( state: <#(State) -> ChildState#>, action: <#(ChildAction) -> Action#> ) ) { state in … }
ID 14:46
For the state argument we need to transform the full identified array to just one single element in the array. We can subscript in with the ID we get from the initial state, and if that lookup fails we can coalesce to the initial state: state: { $0[id: pathState.id] ?? pathState },
ID 15:07
And for the action transformation we can bundle up the child feature action into a StackAction by embedding it into the element case: action: { .element(id: pathState.id, action: $0) }
ID 15:37
This makes it clear why we really do need an abstraction like StackAction . We need both the setPath and element actions in order to properly implement NavigationStackStore .
ID 15:55
Also, before moving on, let’s provide a custom initializer so that we can omit the store parameter name and make use of view builder syntax when construct a navigation stack store: init( _ store: Store< IdentifiedArrayOf<PathState>, StackAction<PathState, PathAction> >, @ViewBuilder root: () -> Root, @ViewBuilder destination: @escaping (PathState) -> Destination ) { self.store = store self.root = root() self.destination = destination }
ID 16:50
Now it is compiling, and this is a functional navigation stack. We’re going to want to make more changes to it to improve things, but let’s quickly get the rest of our application compiling. Using NavigationStackStore
ID 17:16
The theoretical syntax we sketched out a moment ago is not compiling for a few reasons. First of all, the NavigationStackStore requires that we model our action domain with StackAction s so that it can have knowledge of the element and setPath actions.
ID 17:55
So, let’s start there. We will trade out our ad hoc actions in the RootFeature domain for a single StackAction : struct RootFeature: Reducer { … enum Action { … case path(StackAction<Path.State, Path.Action>) // case path(id: Path.State.ID, action: Path.Action) // case setPath(IdentifiedArrayOf<Path.State>) } … }
ID 18:17
This is going to cause a number of errors, but the first we want to fix is down in the forEach reducer operator. Currently we invoke this operator to focus in on the path domain: .forEach(\.path, action: /Action.path) { Path() }
ID 18:31
But this is no longer correct. Previously, the path case of the action represented the identified action, but now it represents a StackAction , which happens to have a case for the identified action, but forEach doesn’t know about that.
ID 18:44
Now, it probably would be a good idea to make forEach understand stack actions, and there will be a lot of reason to do that soon, but for now let’s just keep pushing forward. We need to compose case paths together so that we can first dive into the path case, and then further dive into the element case of the stack action: .forEach( \.path, action: (/Action.path) .appending(path: /StackAction.element) ) { Path() }
ID 19:24
It looks gnarly, but we will focus on making this look nicer later. Right now we are just focused on getting the view layer looking nice.
ID 19:30
Now the forEach is compiling, and that has exposed a lot more compiler errors up in the core reducer. Luckily they are all quite straight forward to fix.
ID 19:37
First, it is no longer correct to listen for child actions in the path like this: case let .path( id: _, action: .counter(.delegate(action)) ):
ID 19:44
We now need to go through the additional StackAction layer. Let’s do it from scratch so that we can see how the compiler can help us each step of the way: case let .path( .element(id: _, action: .counter(.delegate(action))) ):
ID 20:15
So, this is looking quite similar to what it’s like to listen for actions inside a presented feature by destructuring the PresentationAction , except now we have this extra ID that helps know which element in the entire stack is sending an action.
ID 20:35
The next error is down where we destructure setPath , which now also needs to go through the extra layer of StackAction : case let .path(.setPath(path)): state.path = path return .none
ID 21:04
And we’ll move this case up to be with the other case.
ID 21:12
And now everything is compiling, except for the old NavigationStack down below, so let’s comment it out.
ID 21:32
We can also make this code take up less vertical space in the editor by getting a few things onto one line: NavigationStackStore( self.store.scope(state: \.path, action: { .path($0) }) ) { … }
ID 21:52
It’s also worth mentioning, as we’ve done a few times in this series, if Swift had first class support for case paths then some of this code could be greatly shortened. We could use “key path”-like syntax to extract the counter case from the enum, and also use shorter closure syntax for embedding the counter action: } destination: { switch $0 { case .counter: CaseLet( state: \.counter, action: { .counter($0) }, then: CounterView.init ) case .numberFact: CaseLet( state: \.numberFact, action: { .numberFact($0) }, then: NumberFactView.init ) } }
ID 22:26
And this is so short we might even be able to fit it all on one line!
ID 22:45
And that would be amazing, but sadly this does not work today so let’s revert it all.
ID 23:02
OK, everything is now compiling, and so let’s give it a spin. I’m going to first comment out the deep linking we have at the entry point: initialState: RootFeature.State( path: [ // .counter(CounterFeature.State(count: 42)), // .counter(CounterFeature.State(count: 1729)), // .counter(CounterFeature.State(count: -999)), ] ),
ID 23:15
And now running in the simulator we see that seems to work just as it did before. However, there is a bug. If we drill down to another counter we will see that the back button says “0” instead of the count we were previously on, and if we go back we see that screen is indeed back on 0.
ID 23:44
This is happening because we are only observing a very specific subset of changes to the store, in particular when the IDs of the identified array change: WithViewStore( self.store, observe: { $0 }, removeDuplicates: { $0.ids == $1.ids } ) { viewStore in … }
ID 23:57
This means that whenever the NavigationStack reaches into the binding to see what current data is powering the stack it will only see the version of the data from the last time the IDs changed: path: viewStore.binding( get: { $0 }, send: { .setPath($0) } )
ID 24:07
That is what is causing us to accidentally show old, stale data. For right now we are going to employ a quick hack by just reaching into a fresh view store when this binding’s get is called in order to get the most up-to-date data: path: viewStore.binding( get: { _ in ViewStore(self.store, observe: { $0 }) .state }, send: { .setPath($0) } )
ID 24:41
The final version of these tools that go in the library won’t need to resort to these tricks, but let’s just push forward for now.
ID 24:47
Now when we run the simulator we will see it behaves as we expect. We can start a timer, drill down to another counter, and the title in the top-left correctly reflects the previous count. And popping back shows the timer is still going at its current value.
ID 25:04
Everything works just as it did before, but now the call site of constructing a NavigationStackStore looks much, much better: NavigationStackStore( self.store.scope(state: \.path, action: { .path($0) }) ) { Button("Go to counter") { viewStore.send(.goToCounterButtonTapped) } } destination: { switch $0 { case .counter: CaseLet( state: /RootFeature.Path.State.counter, action: RootFeature.Path.Action.counter, then: CounterView.init(store:) ) case .numberFact: CaseLet( state: /RootFeature.Path.State.numberFact, action: RootFeature.Path.Action.numberFact, then: NumberFactView.init(store:) ) } }
ID 25:12
We just provide 3 pieces of information:
ID 25:14
A store scoped to the navigation stack domain.
ID 25:17
A trailing closure for the view shown at the root of the navigation stack.
ID 25:21
And another trailing closure that switches on a piece of state to describe the view to be presented for each type of screen that can appear in the stack. Improving forEach
ID 25:29
So, we’ve greatly improved how one constructs NavigationStack s in the view in order to compose all the various views together that can be pushed onto the stack. All we have to do is scope our store down to the path domain, which consists of an identified array of state and a StackAction , then we describe the root view of the navigation stack, and finally we perform a switch on the enum of features and describe the view that goes with each feature. This is not only more precise, but it’s also more succinct because it now takes less than half the number of lines it took previously. Brandon
ID 25:57
However, in the process of making these improvements to the views we created a bit of a mess in our reducer. We are now composing case paths together in order to get through that extra StackAction layer. Let’s see what it takes to fix that problem, and a few other annoyances.
ID 26:13
Right now when we apply the forEach operator we have to do extra work to compose two case paths. One goes through the path case in our feature’s domain, and then the other goes through the element case of the StackAction enum: .forEach( \.path, action: (/Action.path) .appending(path: /StackAction.element) ) { Path() }
ID 26:31
Also, it’s kind of weird that we have to handle the setPath action in our feature domain: case let .path(.setPath(path)): state.path = path return .none
ID 26:39
We are going to have to do this same work for every single feature that wants a navigation stack, and so it would be nice to hide that away a bit.
ID 26:45
What if we had a dedicated forEach operator that understood StackAction s, which would be reminiscent of what we did with ifLet when we upgraded it to understand PresentationAction s. Then we can hide the case path composition away and we could hide the explicit handling of setPath .
ID 27:06
I’m going to start by copying and pasting the forEach that we currently have in the library: extension Reducer { func forEach< ElementState, ElementAction, ID: Hashable, Element: Reducer >( _ toElementsState: WritableKeyPath< State, IdentifiedArray<ID, ElementState> >, action toElementAction: CasePath< Action, (ID, ElementAction) >, @ReducerBuilder<ElementState, ElementAction> element: () -> Element, file: StaticString = #file, fileID: StaticString = #fileID, line: UInt = #line ) -> _ForEachReducer<Self, ID, Element> where ElementState == Element.State, ElementAction == Element.Action { … } }
ID 27:28
…and making a few changes.
ID 27:31
First we will drop the ID generic and instead force ElementState to be identifiable: func forEach< ElementState: Identifiable, ElementAction, Element: Reducer >(
ID 27:39
The element state key path will focus in on an identified array of ElementState : _ toElementsState: WritableKeyPath< State, IdentifiedArrayOf<ElementState> >,
ID 27:49
And we will replace the tuple of ID and ElementAction with a proper StackAction , which bundles those two concepts together, along with the setPath action: action toElementAction: CasePath< Action, StackAction<ElementState, ElementAction> >,
ID 28:00
Also at this point it may be more honest for us to rename the internal argument name: action toStackAction: CasePath< Action, StackAction<ElementState, ElementAction> >,
ID 28:09
And we will just return some reducer rather than the concrete one that is returned in the library: ) -> some ReducerOf<Self>
ID 28:31
Then in the body of this method we can return a whole new reducer: Reduce { state, action in }
ID 28:38
And we are actually going to quickly sketch this out from scratch because we have never actually build a forEach operator in episodes. It’s all quite straightforward.
ID 28:46
We first need to use the toStackAction case path to first figure out what kind of action we are interpreting. It could be a StackAction or a parent action, and so there are 3 cases to consider: Reduce { state, action in switch toStackAction.extract(from: action) { case let .element(id: id, action: elementAction): break case let .setPath(path): break case .none: // Parent action break } }
ID 29:37
Some of these cases are simpler to handle than others. For example, the .none case means we are dealing with a parent action, so all we have to do is run the parent reducer and return its effects: case .none: return self.reduce(into: &state, action: action)
ID 29:55
The setPath case is also pretty straightforward. We just need to key path into the state to override the state, and then run the parent reducer: case let .setPath(path): state[keyPath: toElementsState] = path return self.reduce(into: &state, action: action)
ID 30:16
Although, there is also another choice here. We could have run the parent reducer first and then cleared out the path: case let .setPath(path): let effects = self.reduce(into: &state, action: action) state[keyPath: toElementsState] = path return effects
ID 30:29
It’s not clear what the right decision is here.
ID 30:36
Over in the ifLet reducer we specifically decided to run the parent before clearing out state: case let (.some(childState), .some(.dismiss)): let effects = self.reduce(into: &state, action: action) state[keyPath: stateKeyPath].wrappedValue = nil …
ID 30:54
And that made a lot of sense because it gives the parent reducer one last chance to inspect the last child state before it is cleared out.
ID 31:07
However, it’s not so clear for setPath . The act of setting the path could be removing elements or it could be adding elements. If an element is removed, you probably want to run the parent reducer before the removal so that the parent can see the last state before removing. And if an element is added you probably want to run the parent reducer after because it allows the parent to perform any additional mutations.
ID 32:02
So, there isn’t a clear right choice of running the parent before or after setting the path, and luckily we will actually have a better way to handle this in the future so we won’t even worry about it right now. We will just leave it where we run the parent before setting the path.
ID 32:19
Now we just have the element case to handle, which happens when a feature inside the stack sends an action. We have both the ID of the feature that sent the action, as well as the actual child feature action itself. There is a little bit more work to do in here.
ID 32:35
First, we should check that we even have state at that ID because if not there is a serious programmer error. It means that somehow a feature’s action was sent while the feature is not even in the stack anymore. This typically happens if effects are still in flight when an feature goes away, and it can cause some subtle and annoying bugs.
ID 32:52
So, we like to warn loudly about this situation, which we will do like so: if state[keyPath: toElementsState][id: id] == nil { XCTFail( "Action was sent for element that does not exist." ) return self.reduce(into: &state, action: action) }
ID 33:31
Next we need to run both the element reducer on the particular piece of state in the identified array, and then merge its effects with the effects from running the parent reducer: return .merge( element .reduce( into: &state[keyPath: toElementsState][id: id]!, action: elementAction ) .map { toStackAction.embed(.element(id: id, action: $0)) }, self.reduce(into: &state, action: action) )
ID 35:19
And things are almost compiling, but we have a complaint that we are defining a public forEach that uses internals, so let’s make forEach internal, as well.
ID 35:30
Now this is compiling, and just like that we can stop explicitly handling the setPath action because that is now handled for us by the forEach operator: // case let .path(.setPath(path)): // state.path = path // return .none
ID 35:39
And we can go back to use the abbreviated form of forEach that does not need to unravel additional layers of stack actions: .forEach(\.path, action: /Action.path) { Path() }
ID 36:01
So, that’s great! We’ve now cleaned up the reducer and the view and things are looking quite succinct. Next time: state ergonomics
ID 36:17
However, we can take things much, much further. Let’s finally look at all this identifiable madness that we have let infect our code. We have needed to conform each of our features’ states to the Identifiable protocol, which typically does not make sense. We just kind of shoehorned it in by adding a randomly generated UUID. That was easy to do, but what wasn’t easy was to then further conform our Path.State enum to be identifiable, which forced us to define a conformance by switching over the enum and calling out to the id of each of our features. That code is a pain to maintain, and will need to be updated every time a new feature is added to the stack. And all of that was annoying enough without even talking about tests, which would be really annoying given all the uncontrolled UUIDs we just sprinkled throughout our code. Stephen
ID 37:09
Further, putting our features into a navigation stack also suddenly forced us to make our features’ state Hashable . That was easy enough to do as ideally all state structs are simple data types that can be automatically made Hashable , but also features can hold lots of state. We may have a really large feature with lots of child features, and hashing all of that data may be quite slow. We could provide a custom hash implementation that just hashes the ID that we’ve been forced to provide, but that is just more manual work to be done and it’s probably not correct to do. What if we did really want a proper Hashable implementation for the state that did hash all the data? We would be out of luck because we’ve trampled on that possibility due to all the strange choices we’ve been forced into.
ID 37:54
So, let’s finally untangle ourselves from hashability and identifiability. We will create a new data type that behaves a lot like IdentifiedArray , but that is tuned specifically for navigation stacks.
ID 38:05
Let’s try it out…next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0234-composable-navigation-pt13 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 .