Video #235: Composable Stacks: State Ergonomics
Episode: Video #235 Date: May 15, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep235-composable-stacks-state-ergonomics

Description
We introduce a complementary StackState wrapper to our navigation tools to solve a few issues that came from repurposing the identified array type. Once implemented and integrated, we will put these tools’ superpowers to work by implementing two new features.
Video
Cloudflare Stream video ID: 3f88251e22f244661da6e0c794105c3f Local file: video_235_composable-stacks-state-ergonomics.mp4 *(download with --video 235)*
References
- Discussions
- the documentation
- Wolfram Alpha
- Composable navigation beta GitHub discussion
- 0235-composable-navigation-pt14
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
We’ve now cleaned up the reducer and the view and things are looking quite succinct.
— 0:10
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
— 1:02
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.
— 1:47
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.
— 1:59
Let’s try it out. Introducing StackState
— 2:02
Let’s quickly recall all the unnecessary annotations we had to make to our types to get things working with navigation stack. We had to make our CounterFeature.State hashable and identifiable: struct CounterFeature: Reducer { struct State: Hashable, Identifiable { let id = UUID() … } … }
— 2:17
From the perspective of the CounterFeature in complete isolation it is weird that it had to provide these conformances, because as far as it is concerned it just needs an Equatable conformance in order to observe state changes in the view. We are adding these extra conformances just to appease requirements in the parent, but ideally the child feature should not need to do anything to appease the parent. And besides that, writing tests for this feature is going to be really annoying with all of these unique UUIDs being generated.
— 2:46
We had to do the same for the NumberFactFeature.State : struct NumberFactFeature: Reducer { struct State: Hashable, Identifiable { let id = UUID() … } … }
— 2:59
And we had to do the same for the RootFeature.Path.State , which even more annoyingly required a manual conformance to Identifiable : struct RootFeature: Reducer { … struct Path: Reducer { enum State: Hashable, Identifiable { … var id: AnyHashable { switch self { case let .counter(state): return state.id case let .numberFact(state): return state.id } } } } … }
— 3:20
So, what can we do to remove all these constraints?
— 3:23
What if we had a wrapper type around IdentifiedArray so that we could hide some of its details: struct StackState<Element> { fileprivate var elements: IdentifiedArrayOf<Element> = [] }
— 4:09
We are even making the inner elements file private because no one on the outside should know that it’s secretly an identified array.
— 4:20
However, we clearly can’t hold onto an identified array of Element , because that would force the Element to be Identifiable and that is what we are trying to avoid. Instead we can wrap the element in a new, private type that is identifiable and hides away that
UUID 4:57
So what we have done here is codify the pattern of making our features Identifiable by slapping in a UUID directly into the StackState data type. We no longer need to make our features identifiable because the moment they are added to the StackState they will get a unique ID associated.
UUID 5:11
For example, currently we are not exposing any API surface area to the outside on StackState . You can’t even append elements to the stack. So, let’s add a quick mutating append method that adds an element to the stack, and in the process of doing so generates a new unique ID for it: mutating func append(_ element: Element) { self.elements.append( Component(id: UUID(), element: element) ) }
UUID 5:53
Note that we are still generating a random, uncontrollable UUID, but at least it is all happening in a central place rather than all over in the user’s feature code. So, when it comes to testing, if we can make this single ID generator controllable we will have a chance at making the entire feature testable.
UUID 6:09
So, this sounds great, but let’s see what it takes to make use of this StackState datatype instead of a plain IdentifiedArray . Let’s start with the forEach operator. I don’t want to break everything we have done so far just yet, so I’m going to copy-and-paste the forEach operator we made a moment ago, and start with the simple change that the key path provided must focus in on some StackState rather than an IdentifiedArray , which means we can also drop the Identifiable conformance on ElementState : extension Reducer { func forEach< ElementState, ElementAction, Element: Reducer >( _ toElementsState: WritableKeyPath< State, StackState<ElementState> >, … ) }
UUID 6:46
Now we also need to change StackAction because setPath should no longer speak the language of IdentifiedArray but instead StackState . However, again, I don’t want to break everything just yet, so I am also going to copy-and-paste this type so that we can make changes.
UUID 7:02
We are going to update _StackAction so that its setPath takes a StackState , and we are going to drop the Identifiable requirement, and further we are going to force the ID to be a UUID right now since that is what we used in the Component : enum _StackAction<State, Action> { case element(id: UUID, action: Action) case setPath(StackState<State>) }
UUID 7:17
And then we will update the forEach operator to use this type: action elementCasePath: CasePath< Action, _StackAction<ElementState, ElementAction> >,
UUID 7:23
Now we can start updating the implementation of the operator to properly make use of StackState . For example, to check if an element exists at an ID we now need to go through the file private elements array: if state[keyPath: toElementsState].elements[id: id] == nil { … }
UUID 7:45
And when running the element reducer on a particular element, we have a few more layers to get through: element .reduce( into: &state[keyPath: toElementsState] .elements[id: id]!.element, action: elementAction ) .map { elementCasePath.embed(.element(id: id, action: $0)) },
UUID 7:59
And that’s it, the operator is now compiling, and it speaks the language of both stack state and stack actions.
UUID 8:08
So, as far as the reducer is concerned we can definitely hide away the details of being identifiable. Let’s move onto the NavigationStackStore . Again I don’t want to make any breaking changes just yet, so let’s copy-and-paste the entire view, underscore it: struct _NavigationStackStore< PathState: Hashable & Identifiable, PathAction, Destination: View, Root: View >: View { … }
UUID 8:33
And see what it takes to disentangle it from the notions of hashability and identifiability: struct _NavigationStackStore< PathState, PathAction, Destination: View, Root: View >: View { … }
UUID 8:43
We’ll first update the store held in the view to be focused in on StackState instead of an identified array, and we’re going to switch to _StackAction since that is the enum that speaks the language of stack state: let store: Store< StackState<PathState>, _StackAction<PathState, PathAction> > And we’ll update the initializer: init( store: Store< StackState<PathState>, _StackAction<PathState, PathAction> >, … )
UUID 8:58
Next, when removing duplicates based on the IDs of the identified array, we will need to further go through the file private elements field: removeDuplicates: { $0.elements.ids == $1.elements.ids }
UUID 9:06
And when constructing the binding for the NavigationStack we will need to pluck out the underlying elements identified array, and further when an identified array is written to the binding we will need to wrap it back up into a StackState : NavigationStack( path: viewStore.binding( get: { _ in ViewStore(self.store, observe: { $0 }) .state .elements }, send: { .setPath(StackState(elements: $0)) } ) ) { … }
UUID 9:39
Things are not compiling right now, but the compiler is really struggling to give us an error message. We can help it out by commenting out all the destination code.
UUID 9:51
Now we see two errors: Initializer ‘init(path:root:)’ requires that ‘StackState<PathState>.Component’ conform to ‘Hashable’ Referencing initializer ‘init(_:observe:)’ on ‘ViewStore’ requires that ‘StackState<PathState>’ conform to ‘Equatable’
UUID 10:05
The element of the collection that powers the stack must be Hashable , and this gives us a great opportunity to flex some of the muscles we have gained with the StackState abstraction. Previously we fixed this problem by forcing the entire element of the collection to be hashable, which spilled over into us making all of our features’ states hashable.
UUID 10:23
But now that we have our own, file private type that is the actual element of the collection, we get to do things that we wouldn’t want to force onto our users. In particular, we can make the Component type equatable and hashable based only on the underlying ID: extension StackState.Component: Hashable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(self.id) } }
UUID 11:14
That fixes the compiler error where we are trying to feed a binding to the NavigationStack .
UUID 11:20
The next error is in our little hack that is trying to quickly grab the state from the store. Now it’s saying that state needs to be equatable, but really we just want the most recent value. We don’t care about remove duplicates at all, and so we will add on another hack: get: { _ in ViewStore( self.store, observe: { $0 }, removeDuplicates: { _, _ in true } ) .state .elements },
UUID 11:48
We want to repeat that this hackiness is not necessary in the final form of these tools. We are just doing this right now because it’s the easiest way to keep moving forward right now.
UUID 11:57
Next we have to update the navigationDestination(for:) identifier so that it is on the look out for Component data being added to the stack rather than plain PathState : .navigationDestination( for: StackState<PathState>.Component.self ) { component in … }
UUID 12:28
And finally we have to update the scope transformation in order to reach into the element state inside the component: state: { $0.elements[id: component.id]?.element ?? component.element }, action: { .element(id: component.id, action: $0) }
UUID 13:04
And now the _NavigationStackStore type is completely compiling, and it serves as a replacement to our original NavigationStackStore where as long as you are willing to use StackState you get to forget about conforming all of your state types to Identifiable and Hashable . That seems like a pretty huge win! Integrating StackState
UUID 13:22
OK, we’ve made about as many changes as we can in a non-breaking way. Let’s start breaking things by updating our feature to use StackState and the new forEach and NavigationStackStore tools.
UUID 13:36
Let’s start by updating RootFeature.State to deal with StackState : struct RootFeature: Reducer { struct State: Equatable { var path: StackState<Path.State> = StackState() } … }
UUID 13:50
Note that we cannot currently use array literal notation like we did with identified array because we have not conformed StackState to ExpressibleByArrayLiteral . We are not going to do that now, and in fact there are very good reasons to not do it at all, but we will discuss that later.
UUID 14:07
We do have a compilation error here because we have not made StackState equatable yet. The Component type is, but not the stack type itself. The naive conditional conformance is not correct unfortunately: extension StackState: Equatable where Element: Equatable {}
UUID 14:34
This synthesized equitability check will delegate down to the Component ’s == , which we have purposely short circuited any actual data checking and instead only check on IDs. But if in your own code if you ever wanted to check if two stacks are equal, like say for tests, you certainly would want to check more than the IDs, which are completely hidden from us. You would want to check the full data inside the stack.
UUID 15:04
So, we do have to implement == ourselves to check everything manually: extension StackState: Equatable where Element: Equatable { static func == (lhs: Self, rhs: Self) -> Bool { guard lhs.elements.count == rhs.elements.count else { return false } return zip(lhs.elements, rhs.elements).allSatisfy { $0.id == $1.id && $0.element == $1.element } } }
UUID 16:13
Now the State struct is compiling.
UUID 16:15
Next we want to use the new forEach operator that makes use of both stack state and actions, but remember we had copied-and-pasted the operator and _StackAction so that we could experiment in a non-breaking way. Let’s clean that up now. We will delete the old forEach operator and StackAction enum that speaks identified array.
UUID 16:44
And we will rename our _StackAction to just StackAction . With that done our forEach compiles just fine: .forEach(\.path, action: /Action.path) { Path() }
UUID 17:02
Next let’s fix the view. It’s currently using the NavigationStackStore that only speaks identified array. Let’s delete that view. And rename our _NavigationStackStore to just NavigationStackStore .
UUID 17:18
Now everything in this file is compiling, and we just have one last error left in the project, and it’s back in the entry point where we try to deep link to a specific stack of features: RootView( store: Store( initialState: RootFeature.State( path: [ // .counter(CounterFeature.State(count: 42)), // .counter(CounterFeature.State(count: 1729)), // .counter(CounterFeature.State(count: -999)), ] ), reducer: RootFeature()._printChanges() ) )
UUID 17:30
This is no longer working because, as we mentioned earlier, we have yet to provide a conformance of StackState to ExpressibleByArrayLiteral and so it’s not possible to instantiate the path with an array.
UUID 17:41
We actually think that StackState should not conform to this protocol because it makes it seem like for all intents and purposes that it behaves like a plain array, when that is not true at all. Behind the scenes there is a shadow world of IDs that we are secretly maintaining, and we keep those IDs in sync with their associated state.
UUID 18:04
If you wanted to do something seemingly innocent like create a new path StackState by inserting a new counter between two existing features: /* state.path = [ state.path[0], .counter(CounterFeature.State()), state.path[1] ] */
UUID 18:35
Then you would be unknowingly be creating all new IDs for those features, and so as far as the NavigationStack is concerned you would be creating all new screens for the stack. That may cause the stack to navigate things in a way you do not expect.
UUID 19:08
So, we want the syntax for creating a stack from an array to be a little noisier, and in fact we can take some inspiration from SwiftUI’s NavigationPath , which is their special type that is tuned specifically for navigation stacks. That type exposes an API for initializing a path from an array: let path = NavigationPath([1, 2, 3])
UUID 20:02
Let’s do this same for our special type that is tuned specifically for NavigationStackStore s: fileprivate init( elements: IdentifiedArrayOf<Component> = [] ) { self.elements = elements } init() {} init<S: Sequence>(_ elements: S) where S.Element == Element { self.elements = IdentifiedArray( uncheckedUniqueElements: elements.map { Component(id: UUID(), element: $0) } ) }
UUID 21:53
With that we can update the entry point for deep linking 3 layers in: RootView( store: Store( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 42)), .counter(CounterFeature.State(count: 1729)), .counter(CounterFeature.State(count: -999)), ]) ), reducer: RootFeature()._printChanges() ) )
UUID 22:06
Now the entire application is compiling, but before running the application to make sure it still works, let’s do some clean up. Now that we are completely running off of StackState it should be possible to drop a whole bunch of Identifiable and Hashable conformances.
UUID 22:25
To start, we’d love if the CounterFeature.State could go back to only being Equatable , which means we can drop the id field: struct CounterFeature: Reducer { struct State: Equatable { // let id = UUID() … } … }
UUID 22:36
And same for NumberFactFeature.State : struct NumberFactFeature: Reducer { struct State: Equatable { // let id = UUID() … } … }
UUID 22:48
And even better we can remove those conformances from Path , which means we can drop the id field and the switch statement that was necessary to implement it: struct Path: Reducer { enum State: Equatable { case counter(CounterFeature.State) case numberFact(NumberFactFeature.State) } … }
UUID 22:59
So, that’s all looking great, but somehow we have now just broken something in our view.
UUID 23:06
It looks like SwiftUI is no longer happy with our NavigationLink s: NavigationLink( value: RootFeature.Path.State.counter( CounterFeature.State(count: viewStore.count) ) ) { … }
UUID 23:12
We are getting an error that this initializer requires that the value be Hashable , hence RootFeature.Path.State must go back to Hashable : Initializer ‘init(value:label:)’ requires that ‘RootFeature.Path.State’ conform to ‘Hashable’
UUID 23:20
Well, that’s not great, but actually this code is just now completely wrong. This code only works if the type of value we associate with the link matches the type of element held in the collection powering the stack. But now secretly over in the NavigationStackStore we are wrapping all of our elements in the private Component type: .navigationDestination( for: StackState<PathState>.Component.self ) { state in
UUID 23:54
We couldn’t construct this Component value even if we wanted to because it’s been made private, and it’s private for a good reason. Its whole purpose is to hide away the fact that we are manually creating and managing IDs for each feature pushed onto the stack, and we don’t want the user of the library to have to worry about that at all.
UUID 24:18
So, what we need to do is provide our own NavigationLink initializer that allows using any kind of value, not just Hashable values, but then under the hood it will bundle that value into a Component so that it gets a unique ID: extension NavigationLink where Destination == Never { init<Element>( state element: Element, @ViewBuilder label: () -> Label ) { self.init( value: StackState<Element>.Component( id: UUID(), element: element ), label: label ) } }
UUID 25:37
And then we have to remember to use this initializer anytime we are dealing with NavigationStackStore s: NavigationLink( state: RootFeature.Path.State.counter( CounterFeature.State(count: viewStore.count) ) ) { … }
UUID 25:50
This now compiles, and we can do the same for the number fact navigation link: NavigationLink( state: RootFeature.Path.State.numberFact( NumberFactFeature.State(number: viewStore.count) ) ) { … }
UUID 26:01
Now everything compiles again, and it should work exactly as it did before. If we launch the app we will still we are deep linked multiple levels in. We can count up and down, we can start and stop the timer, we can drill down more layers, and we can pop all the way back to the root. And everything is now powered by StackState rather than IdentifiedArray . Introspecting StackState
UUID 26:39
So, we now have a really nice duality in our state and actions when it comes to modeling a feature that uses a navigation stack. We use StackState in the features state to model all the features that can be pushed onto the navigation stack, and we use StackAction to represent all the actions that can be sent in each of the element of the stack, and it tags each action with the unique ID that represents that element. Stephen
UUID 27:03
But now let’s flex some of the muscles we’ve just gained. Let’s see how by concisely modeling the state for a navigation stack we get instant and complete introspection into what is happening in every feature pushed onto the stack.
UUID 27:15
This can be really important in a variety of situations. For example, say you were building a stack for a multi-step signup or onboarding flow, you wanted to provide a kind of breadcrumb like UI that describes how far along into the process the user is. You can’t do that unless you can analyze what kind of data is on the stack.
UUID 27:31
So let’s dive in.
UUID 27:36
We are going to add another silly feature to an already very silly toy application. We are going to add a little floating UI to the bottom of the navigation stack when one or more features are pushed onto the stack. And in that UI we will show the sum of all the counters across the entire stack.
UUID 27:50
Now, where should the summation value live? It’s not really state that needs to be stored in the feature: struct RootFeature: Reducer { struct State: Equatable { var path: StackState<Path.State> = StackState() var sum = 0 } … }
UUID 28:03
…because it’s only something that depends on other state. It doesn’t need to independently exist on its own.
UUID 28:09
So, a computed property would be better: var sum: Int { }
UUID 28:12
Then we could iterate over the path and add up all the counter feature counts: var sum: Int { for element in self.path { } } For-in loop requires ‘StackState<RootFeature.Path.State>’ to conform to ’Sequence’
UUID 28:22
Well, already we hit a roadblock. StackState is quite opaque at this moment. The only public API it has is an initializer and an append method. We haven’t yet exposed any collection-like API, so let’s see what it takes to do that.
UUID 28:35
We want StackState to conform to the Collection protocol so that we can iterate over the stack to find all the counter features and grab its count: extension StackState: Collection { }
UUID 28:56
The Collection protocol has a lot of functionality, but a lot of it comes for free once you implement a few core things. If we look up the documentation for Collection we will helpfully find the bare minimum of requirements in order to conform: Note To add Collection conformance to your type, you must declare at least the following requirements: The startIndex and endIndex properties A subscript that provides at least read-only access to your type’s elements The index(after:) method for advancing an index into your collection
UUID 29:25
And luckily for us we can simply delegate down to the underlying elements identified array for all of these requirements: extension StackState: Collection { public var startIndex: Int { self.elements.startIndex } public var endIndex: Int { self.elements.endIndex } public func index(after i: Int) -> Int { self.elements.index(after: i) } public subscript(position: Int) -> Element { self.elements[position].element } }
UUID 30:07
With that done we not only get to iterate over stacks and count their elements, but a whole host of algorithms instantly become available.
UUID 30:17
For example, instead of iterating over the path in order to mutate some local summation data, we could use reduce and tally the information more directly: self.path.reduce(into: 0) { sum, element in }
UUID 30:39
Then in here we can switch on the feature state: switch element { case .counter(_): <#code#> case .numberFact(_): <#code#> }
UUID 0:00
And we can handle each case individually. The only case we care about for summing is the counter case, and so the numberFact case can be ignored: self.path.reduce(into: 0) { sum, element in switch element { case let .counter(counterState): sum += counterState.count case .numberFact: break } }
UUID 31:09
So, it may not seem like much, but this is incredibly powerful. We have complete introspection into everything happening inside the features on the stack, and can easily aggregate data as needed.
UUID 31:20
It’s worth comparing this to the NavigationPath tool that comes with iOS 16. In early versions of the docs for NavigationPath it was described as a “type erased collection”, which would lead you to believe it has some collection-like functionality. However, that’s not really the case. Its collection functionality is extremely limited. And so probably for that reason, later versions of the docs started describing NavigationPath as: Note A type-erased list of data representing the content of a navigation stack.
UUID 31:50
Now the NavigationPath type is very interesting and has some powerful tricks up its sleeves, but because it is mostly type erased, SwiftUI has decided to keep the type quite opaque. There are very few operations you can perform on values.
UUID 32:03
For example, you can construct a path: func foo() { var path = NavigationPath() }
UUID 32:15
You can append things to the path: func test() { var path = NavigationPath() path.append(1) path.append(2) path.append(3) }
UUID 32:23
You can even append any kind of data, as long as its hashable: func test() { var path = NavigationPath() path.append(1) path.append(2) path.append(3) path.append("Hello") path.append(true) }
UUID 32:33
You can also ask the path for its length: path.count path.isEmpty
UUID 32:44
…and remove elements from the end of the path: path.removeLast() path.removeLast(2)
UUID 32:52
But that is about it.
UUID 32:55
You cannot insert elements anywhere into the path other than the end: path.insert
UUID 33:02
And you can’t remove from the path other than at the end: path.remove(at:)
UUID 33:13
You can’t even iterate over it: for element in path { }
UUID 33:23
The NavigationPath type is a very focused, singular use tool that is only meant to be used with NavigationStack in which the only two real operations you can do is push things onto the stack or pop off some number layers.
UUID 33:37
In our opinion, our StackState type lies on a spectrum in terms of power and expressiveness. At one end of the spectrum is a full blown random access, range replaceable and mutable collection, which is what one of the initializers on NavigationStack takes: @MainActor init( path: Binding<Data>, @ViewBuilder root: () -> Root ) where Data: MutableCollection, Data: RandomAccessCollection, Data: RangeReplaceableCollection, Data.Element: Hashable
UUID 34:07
That is a very restrictive kind of collection because it offers so much functionality. You can sort them, shuffle them, swap their elements, and a whole bunch more. That gives you a ton of power for data manipulation and introspection, but at the same time not every collection can conform to all of those protocols and you have to be very precise with what kind of data is held in the collection. Array conforms to all of these protocols, and its probably the simplest data type to reach for when using this API directly.
UUID 34:35
On the other side of the spectrum is NavigationPath , which is an opaque type in its own right that doesn’t conform to any collection protocols: @MainActor init( path: Binding<NavigationPath>, @ViewBuilder root: () -> Root ) where Data == NavigationPath
UUID 34:46
SwiftUI rigidly restricts what you are allowed to do with this type, but the benefit is that you can stick any kind of data into the collection and have the navigation react to that data.
UUID 34:55
We feel that StackState lies somewhere between these two extremes in the spectrum. A little more relaxed than needing a full random access, range replace and mutable collection of hashable elements, but it’s also got more API than NavigationStack in that it exposes a bit more collection interface.
UUID 35:12
In short, we think it’s the perfect tool for making use of navigation stacks in the Composable Architecture.
UUID 35:16
But, let’s get back to the feature we are trying to implement. We want to overlay a little view over the entire stack when at least one feature is pushed onto the stack, and in that view we want to show the total sum across all counter features.
UUID 35:29
Let’s start by getting some view hierarchy into place and see how that dictates everything else. We’ll add an overlay to the NavigationStackStore : NavigationStackStore( store: self.store.scope( state: \.path, action: { .path($0) } ) ) { … } .overlay(alignment: .bottom) { }
UUID 35:43
And inside the overlay we want to roughly say that if we are not at the root, show the summation: if viewStore.isSummaryVisible { Text("Sum: \(viewStore.sum)") }
UUID 36:03
So, we need access to those two pieces of state in order to implement this feature. To do this we will introduce some RootView specific view state that holds onto only that state: struct ViewState: Equatable { let isSummaryVisible: Bool let sum: Int }
UUID 36:33
And we will create an initializer that can transform the RootFeature.State into this view state: init(state: RootFeature.State) { self.isSummaryVisible = !state.path.isEmpty self.sum = state.sum }
UUID 36:57
And now we can finally chisel away at the state that the view needs to do its job rather than observing all of the root feature state: WithViewStore( self.store, observe: ViewState.init ) { viewStore in … }
UUID 37:15
And with that the view is actually compiling. It probably doesn’t look great, but let’s take it for a spin in the preview just to see. We can drill down to a counter, count up a few times to see the sum go up. Then we can drill in again and count up to see the sum increase even more. We can even start a timer on a bunch of counter screens and we will see the sum live update as the timers tick.
UUID 37:55
So, that’s great, but let’s make this a little nicer, and gives us a great way to show off why driving all of navigation from state is so powerful, and why being able to preview the full feature at once is also powerful.
UUID 38:08
If I wanted to make a change to the style of this little floating UI, say add a shadow: if viewStore.isSummaryVisible { Text("Sum: \(viewStore.sum)") .shadow(color: .black.opacity(0.3), radius: 5, y: 5) }
UUID 38:21
I now need to drill back into the counter feature from the preview, and would also need to count up if I was styling something specific to that.
UUID 38:30
Even if I make a small change to the shadow, like decrease its value: .shadow(color: .black.opacity(0.2), radius: 5, y: 5)
UUID 38:39
The stack pops back to the root and I have drill in again.
UUID 38:49
Well, instead of doing all of that, let’s just start the preview in a state where we already have a counter feature pushed onto the stack: RootView( store: Store( initialState: RootFeature.State( path: StackState([ .counter(CounterFeature.State(count: 100)) ]) ), reducer: RootFeature() ._printChanges() ) )
UUID 39:17
Now we can make any changes we want to the floating UI and we get to preview it immediately: if viewStore.isSummaryVisible { Text("Sum: \(viewStore.sum)") .padding() .background(Color.white) .transition(.opacity.animation(.default)) .clipped() .shadow(color: .black.opacity(0.2), radius: 5, y: 5) }
UUID 40:11
Each step of the way we could instantly see how our change affected the style of the UI, and that’s great. We can even see that drill down to the first counter feature causes the summary view to animate.
UUID 40:21
While we are here, let’s make a quick improvement to this root view. Right now the entire view is wrapped around WithViewStore even though just this small summary view needs access to that state. The only other place the view store is needed is in the “Go to counter” button in order to send an action, but also since there is no custom logic around that button it could just as easily use a NavigationLink . So, let’s do that: NavigationLink( state: RootFeature.Path.State.counter( CounterFeature.State() ) ) { Text("Go to counter") }
UUID 41:11
We can even make this a bit nicer by providing some defaults to the Path.State enum cases: struct Path: Reducer { enum State: Equatable { case counter( CounterFeature.State = CounterFeature.State() ) … } … }
UUID 41:22
And now the NavigationLink can be shortened to: NavigationLink(state: RootFeature.Path.State.counter()) { Text("Go to counter") }
UUID 41:35
Now we can get rid of the goToCounter action entirely.
UUID 41:53
And now we can localize the state observation to be just around the summary view: WithViewStore( self.store, observe: ViewState.init ) { viewStore in if viewStore.isSummaryVisible { Text("Sum: \(viewStore.sum)") .padding() .background(Color.white) .transition(.opacity.animation(.default)) .clipped() .shadow(color: .black.opacity(0.2), radius: 5, y: 5) } }
UUID 42:21
So we can see that we get a complete view into every piece of state held in the StackState type, and that is in stark contrast to the NavigationPath which is extremely opaque and not inspectable at all. And we not only have infinite introspection into the state, but we also have infinite introspection into the actions happening inside each feature.
UUID 42:41
We are even already making use of this functionality where we are listening for delegate actions in the counter feature: case let .path( .element(id: _, action: .counter(.delegate(action))) ):
UUID 42:48
It may not seem like much, but this is an amazing line of code. Just by virtue of the fact of how features in the Composable Architecture are glued together we get instant communication patterns from child to parent and parent to child. It’s really incredible to see, and we will be making use of this power in future episodes where we build more real world applications using the Composable Architecture. New feature, existing stack
UUID 43:10
OK, so we just did a major flex of our muscles by showing that we can instantly inspect what is happening inside each element of the navigation stack, and use that information to display a little summary view that floats above the stack.
UUID 43:21
There’s a lot more we could explore with respect to parent-child communication in stacks, but we will save that for another time. Right now we have a pretty robust set of tools for making use of navigation stacks in features built with the Composable Architecture, and those tools are even pretty ergonomic. Brandon
UUID 43:36
But, what does it take to add an all new feature to an existing stack? We haven’t gotten to see that process from beginning to end because we were kind of simultaneously improving the tools while also building new features.
UUID 43:48
It turns out it doesn’t take too many steps compared to vanilla SwiftUI, but then of course you get tons of benefits right out of the box.
UUID 43:58
So let’s check that out.
UUID 44:01
We are going to add yet another silly feature to our increasingly silly toy application. This this time it’s going to be a drill down to a new feature that can make a network request in order to fetch a prime number based on the current count. So if the current count is 100, then we will be able to tap a button to fetch the 100th prime and show an alert.
UUID 44:22
We are going to breeze through implementing this feature because there aren’t any new, important lessons that haven’t already been covered.
UUID 44:32
We’ll start with a new Reducer conformance: struct PrimeNumberFeature: Reducer { }
UUID 44:39
The state of the feature will hold onto an alert for showing the prime number, a boolean to indicate the request is in flight, as well as the number that we are currently inspecting. We will also have actions for tapping the prime button, receiving the prime data from the effect, and any actions in the alert, of which there aren’t any right now: struct State: Equatable { @PresentationState var alert: AlertState<Action.Alert>? var isLoading = false let number: Int } enum Action: Equatable { case alert(PresentationAction<Alert>) case nthPrimeButtonTapped case response(TaskResult<Int>) enum Alert: Equatable {} }
UUID 45:48
Next we implement the body of the reducer by first implementing the core logic, including when the button is tapped and when we get a response from the network request, although we are omitting the actual effect for right now, and at the end we use the ifLet operator to enhance the reducer with the alert functionality: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .alert: return .none case .nthPrimeButtonTapped: state.isLoading = true return .task { [number = state.number] in <#???#> } case let .response(.success(prime)): state.alert = AlertState { TextState( "The \(state.number)th prime is \(prime)." ) } state.isLoading = false return .none case .response(.failure): state.alert = AlertState { TextState("Something went wrong :(") } state.isLoading = false return .none } } .ifLet(\.$alert, action: /Action.alert) }
UUID 46:44
We left the effect part blank because there is some basic library code we need to paste in to make a network request. We are going to make use of another API to fetch the “nth” prime, and we’ve actually used this service in past episodes for a similar purposes.
UUID 47:05
It’s called Wolfram Alpha , and it is a general purpose computing platform. We will first paste in a data type that represents the kinds of responses we can get from Wolfram, and in fact we will do this in a new file, WolframAlpha.swift: struct WolframAlphaResult: Decodable { let queryresult: QueryResult struct QueryResult: Decodable { let pods: [Pod] struct Pod: Decodable { let primary: Bool? let subpods: [SubPod] struct SubPod: Decodable { let plaintext: String } } } }
UUID 47:27
And then we will paste in a helper that can construct a URL from a query that can be used to request data from Wolfram Alpha: import Foundation func wolframRequest(query: String) -> URL { var components = URLComponents( string: "https://api.wolframalpha.com/v2/query" )! components.queryItems = [ URLQueryItem(name: "input", value: query), URLQueryItem(name: "format", value: "plaintext"), URLQueryItem(name: "output", value: "JSON"), URLQueryItem(name: "appid", value: …), ] return components.url(relativeTo: nil)! }
UUID 47:41
With that we can perform the effect directly in the reducer, including making the async network request. We are again not going to take the time to actually control this effect, and instead wait until we actually need that power: return .task { [number = state.number] in await .response( TaskResult { let (data, _) = try await URLSession.shared.data( from: wolframRequest(query: "prime \(number)") ) let result = try JSONDecoder() .decode(WolframAlphaResult.self, from: data) if let prime = ( result.queryresult .pods .first(where: { $0.primary == .some(true) })? .subpods .first? .plaintext ).flatMap(Int.init) { return prime } else { struct PrimeError: Error {} throw PrimeError() } } ) }
UUID 48:05
It’s intense, but it’s this way because when you query Wolfram Alpha they send you back many potential answers to your question. So we jump through some hoops to get the first “primary” answer and convert it to a number.
UUID 48:33
That’s all that is needed for the reducer feature. The view is also quite simple and we will just paste it in: struct PrimeNumberView: View { let store: StoreOf<PrimeNumberFeature> var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in Form { Button { viewStore.send(.nthPrimeButtonTapped) } label: { HStack { if viewStore.isLoading { ProgressView() } Text( "What is the \(viewStore.number)th prime?" ) } } } .alert( store: self.store.scope( state: \.alert, action: { .alert($0) } ) ) } } }
UUID 48:50
It’s just a simple button that when tapped sends an action, and we integrate the alert to be driven off the alert state.
UUID 49:17
That right there is enough to get a basic preview into place: PrimeNumberView( store: Store( initialState: PrimeNumberFeature.State(number: 999), reducer: PrimeNumberFeature() ) ) .previewDisplayName("Prime number")
UUID 49:28
And if we tap the button we will find that the 999th prime is 7,907.
UUID 49:45
OK, so we have a basic Composable Architecture in place. To be clear, everything we have done so far has to be done no matter what if you are building features in the Composable Architecture. This is just a new isolated feature.
UUID 49:57
Where things get interesting is when we try integrating this feature into the navigation stack.
UUID 50:11
First we will add a primeNumber case to both the state and action enums on the Path reducer: struct Path: Reducer { enum State: Equatable { … case primeNumber(PrimeNumberFeature.State) } enum Action { … case primeNumber(PrimeNumberFeature.Action) } … }
UUID 50:55
And we will integrate the PrimeNumberFeature reducer into the Path reducer by adding another scope: var body: some ReducerOf<Self> { … Scope( state: /State.primeNumber, action: /Action.primeNumber ) { PrimeNumberFeature() } }
UUID 51:15
That is all it takes as far as the reducer is concerned. If we needed some kind of integration between this new child domain with the parent, then we could start switching on its actions: case .path( .element( id: _, action: .primeNumber(<#PrimeNumberFeature.Action#>) ) )
UUID 51:48
But we don’t need that right now so there is nothing to do.
UUID 51:57
And amazingly we now have compiler errors letting us know of the various spots we need to handle due to the new stack destination. First we need to update the sum computed property to handle the new prime number feature: var sum: Int { self.path.reduce(into: 0) { sum, element in switch element { case let .counter(counterState): sum += counterState.count case .numberFact, .primeNumber: break } } }
UUID 51:57
And then down in the view we need to handle the new case to describe the view that is presented: } destination: { switch $0 { … case .primeNumber: CaseLet( state: /RootFeature.Path.State.primeNumber, action: RootFeature.Path.Action.primeNumber, then: PrimeNumberView.init(store:) ) } }
UUID 52:51
And that is all it takes. That was roughly 3 steps: add some cases to the Path reducer domain, add a Scope operation to the body of the reducer, and add a new case to the destination switch.
UUID 53:11
Of course we don’t yet have a link to take us to this new feature, but that’s easy enough: NavigationLink( state: RootFeature.Path.State.primeNumber( PrimeNumberFeature.State(number: viewStore.count) ) ) { Text("Go to prime number for \(viewStore.count)") }
UUID 53:44
And we can now give the demo for a spin. We are able to drill down to a counter feature, count up a few times, then drill down into a prime number features and ask for the “nth” prime. It all just works.
UUID 54:37
And of course we have infinite introspection capabilities for us to see what is happening inside the stack at anytime. We’ve already taken advantage of this by seeing that we can sum all the counts across all the features pushed onto the stack, but let’s take one last opportunity to flex this muscle.
UUID 54:56
We are going to make it so that the little floating summary view at the bottom of the screen goes away when an alert is displayed in any feature in the stack. Currently we determine if the view is visible based on whether or not anything is on the stack, but let’s beef it up the logic by reducing over the stack so that we can inspect each feature: self.isSummaryVisible = !state.path.isEmpty && !state.path.reduce(into: false) { isAlertShown, element in switch element { case .counter: case let .numberFact(numberFactState): case let .primeNumber(primeNumberState): } }
UUID 56:18
There’s only two features that have an alert, the NumberFactFeature and PrimeNumberFeature , and so in those two cases we can check if the alert state is non- nil to determine if we should not show the summary view: switch element { case .counter: break case let .numberFact(numberFactState): isAlertShown = isAlertShown || numberFactState.alert != nil case let .primeNumber(primeNumberState): isAlertShown = isAlertShown || primeNumberState.alert != nil }
UUID 57:14
And with just that small change we can give it a spin. If we drill down to either the number fact or prime number features and cause an alert to appear, the summary view magically animates away. Next time: effect cancellation
UUID 57:54
So, we think it’s pretty incredible how easy it is to add new features to an existing navigation stack. It only took about 3 steps: you add the feature’s domain to the Path reducer, you add a scope to the Path reducer’s body, and finally you add the view to the NavigationStackStore ’s destination closure.
UUID 58:34
And once you complete those few steps you get immediate and infinite introspection into everything that is happening in the feature when it is on the stack. This includes being able to traverse and aggregate data across the elements of the stack, and the ability to see every single action sent into the child feature. Stephen
UUID 58:56
But, as amazing as this all seems, there are some serious problems lurking in the shadows, and they are reminiscent of what we experienced with our presentations APIs for sheets, popovers and covers.
UUID 59:06
One of the big problems we saw with those forms of navigation, and in particular the ifLet operator, is that when the child feature was dismissed, its effects were not cancelled. That allowed effects to continue feeding data into the system even long after the child feature had gone away.
UUID 59:20
This problem also exists in our navigation stack, and the forEach operator, so let’s see how it can happen and what it takes to fix it…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 0235-composable-navigation-pt14 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 .