Video #231: Composable Stacks: vs Trees
Episode: Video #231 Date: Apr 17, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep231-composable-stacks-vs-trees

Description
It’s finally time to tackle navigation stacks in the Composable Architecture! They are a powerful, new tool in SwiftUI and stray a bit from all the other forms of tree-based navigation we’ve explored. Let’s compare the two styles and see what it takes to integrate stacks into the library’s navigation tools.
Video
Cloudflare Stream video ID: 480185d0c89d0838b83344de4ded2388 Local file: video_231_composable-stacks-vs-trees.mp4 *(download with --video 231)*
References
- Discussions
- its own repo
- Composable navigation beta GitHub discussion
- 0231-composable-navigation-pt10
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
OK, we have now introduced a property wrapper into our navigation tools, first to deal with some potential performance and stack overflow problems, but it also turned out to be the perfect spot for us to squirrel away some extra information so that we could make the tools even better. Stephen
— 0:20
So, this is all looking great, but we still have yet to discuss what is probably the hottest topic when it comes to navigation on Apple’s platforms, and that’s navigation stacks. In particular, the initializer of NavigationStack that takes a binding to a collection of data which drives the pushing and popping of screens to the stack. This was introduced in iOS 16 and kinda turned everything upside down relative to how navigation had been done in SwiftUI for the 3 years prior.
— 0:46
Stack-based navigation is where you model all the different screens you can drill-down to as a single, flat array of values. When a value is added to the array, a drill-down animation occurs to that screen, and when a value is removed from the array a pop-back animation occurs.
— 0:59
This stack-based style of navigation is in stark contrast with what we like to call “tree-based” navigation, which is what we have been doing so far in this entire series of episodes. In that model, each feature in the application acts as a branching point for all the different places you can navigate to, and then each of those destinations has branches, and on and on and on.
— 1:18
Each style has lots of positives and some negatives, so let’s dig a little deeper into a comparison of the two styles, and see what the Composable Architecture has to say about stack-based navigation. Tree- vs stack-based navigation
— 1:31
Let’s start by recalling the differences between tree-based navigation, which is the type of navigation we have been focused on for the past many episodes, and stack-based navigation, which is what iOS 16 brings to the table. We’ve passingly mentioned these differences in past episodes, and we discussed it quite a bit during our livestream, but a quick refresher would be nice.
— 1:49
Tree-based navigation is the type of navigation where each feature describes all the possible destinations it can immediately navigate to as an enum, and the aggregate of all those enums forms a tree-like structure that describes all valid navigation paths in your application.
— 2:03
The navigation in our little inventory app isn’t super complicated right now as there is only one single feature that has a Destination enum of possibilities, but let’s look at it nonetheless. When constructing the root state to power the entire application we have the ability to use Swift autocomplete to guide us into all the possible destinations we can navigate to: AppFeature.State( inventory: InventoryFeature.State( destination: .<#⎋#> … ) )
— 2:54
When we autocomplete the . for destination we see that we can navigate to the “add item” modal, an alert, the “duplicate item” popover, and finally the “edit item” drill down. And if any of those features had further destinations we would be able to use autocomplete to guide us to the screen we want to navigate to next.
— 3:12
Now, we don’t currently have any further navigation destinations, so this “tree” consists of a single root node with 4 branches coming out of it.
— 3:21
But we do have another project we built in the past that shows off tree-based navigation in its full glory. It is not a Composable Architecture application, but during our “ Modern SwiftUI ” series we built a pretty complex app with many navigation paths, and it was called “Standups.” It’s essentially a port of a fun demo application Apple put out a few years ago called “Scrumdinger”, but with a few extra bells and whistles from us to demonstrate how domains could be modeled more concisely and how testing could be better embraced.
— 3:47
We even extracted the code out to its own repo and encouraged others to “re-port” it into their own preferred styles. Let’s quickly open up that code base.
— 3:51
This application is built using the techniques of tree-based navigation, and we can see that by trying to construct the root model that powers the entire application. We can see it takes a destination argument, which is optional, and if we specify it we get to describe where we want to be deep-linked into when the application starts: StandupsList( model: StandupsListModel( destination: .<#⎋#> ) )
— 4:39
There are 3 choices: we can deep-link into the “add standup” modal, or an alert, or the “detail” screen for a particular standup.
— 4:47
Let’s go into the detail of a particular standup, and we even have a mock we can use real quick: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel( destination: .<#⎋#>, standup: .designMock ) ) ) )
— 5:01
The detail feature also has a destination argument, and from here we can again use autocomplete to help us figure out where we want to deep-link to next. We’ve got 4 choices: we can deep-link into an alert, or the edit modal for the standup, or into a previously recorded meeting in the standup, or finally a drill-down into the record feature for recording a new meeting.
— 5:17
Let’s further go into the record feature, and then we will stop there: StandupsList( model: StandupsListModel( destination: .detail( StandupDetailModel( destination: .record( RecordMeetingModel( standup: .designMock ) ), standup: .designMock ) ) ) )
— 5:29
Now we can more clearly see the tree-like structure of navigation in our app. Each feature provides a branch point from which we can hop off to another destination, and each destination represents a branch.
— 5:42
If we run the application we will see we are immediately launched into the record feature, and we can even end the meeting to see it was indeed recorded.
— 6:08
So, this style of navigation is incredibly powerful, but it does have its own unique pros and cons.
— 6:13
The biggest pro is that it is a very concise way of describing navigation in an application. We get to statically define a finite number of navigation paths that our app supports, and autocomplete helps each step of the way. If some sequence of navigation simply doesn’t make sense, such as navigating to a meeting screen from a record screen, then you simply do not model that possibility in your enums. Easy peasy.
— 6:34
Another pro, and it’s a direct consequence of the pro we just mentioned, is that features with tree-based navigation are more self-contained when run in isolation. For example, I can jump over to the StandupDetail feature, start its preview, and test the full flow of drilling down into a past meeting or even recording a whole new meeting, all from the preview. And thanks to us controlling our dependencies we can even see how the speech recognizer behaves, which is incredible.
— 7:14
And finally, another big pro is that tree-based navigation unites all the forms of navigation into basically the same kind of API. We’ve been seeing this with the tools we are building for the Composable Architecture, but the same is even true in vanilla SwiftUI. If we look at the bottom of the file we will see something really incredible. 4 forms of navigation in this feature are all described in basically the same way: .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.meeting ) { $meeting in … } .navigationDestination( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.record ) { $model in … } .alert( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.alert ) { action in … } .sheet( unwrapping: self.$model.destination, case: /StandupDetailModel.Destination.edit ) { $editModel in … }
— 8:03
But, tree-based navigation also has its drawbacks.
— 8:07
First, and most importantly, the APIs for tree-based navigation are quite buggy in SwiftUI, especially when it comes to drill-down navigation. Deep-linking with navigationDestination is basically broken in SwiftUI and so makes it quite frustrating to use. Well, that was until the most recent release of SwiftUI, which comes in iOS 16.4 and Xcode 14.3. Many, many bugs in SwiftUI navigation have been fixed with this release, making it a much more attractive option, though there are still a few bugs left.
— 8:36
Second, tree-based navigation cannot easily express certain kinds of complex navigation paths, such as recursive ones. For example, if you had a “wiki-style” application, such as an IMDB film database app, then you can navigate to a movie, then to a list of actors, then to a particular actor, then to their list of movies, and then back to the same movie page. Other “wiki-style” applications include Twitter and Mastodon clients, and even Apple’s App Store app. Tree-based navigation cannot easily support these use cases since it would require a recursive enum. It’s possible, but it can be difficult.
— 9:08
And finally, the one that I think worries the most people, is that tree-based navigation couples features together. In order to build the StandupDetail feature we also need to build the RecordMeeting feature. That makes working in leaf features enjoyable since it doesn’t have many other dependencies, but it can be a pain to work towards the root of the application since compile times tend to get much longer.
— 9:28
So, that’s tree based navigation. What is stack-based navigation?
— 9:32
First of all, stack-based navigation is only relevant when discussing drill down navigation, and not other forms of navigation such as sheets, popovers, alerts, etc. That wasn’t the case for tree-based navigation, which unified all those forms of navigation under a single umbrella.
— 9:46
In stack-based navigation you model all of the possible screens that can be navigated to in a single navigation stack as a flat array of values. So, where previously we represented drilling down to the detail screen and then the record screen as a nested data type: destination: .detail( destination: .record(…) )
— 10:00
…in the stack-based world this would become a flat array. Roughly something like this: path: [.detail(…), .record(…)]
— 10:11
In fact, during our livestream a few months back we even live-refactored the Standups application from a tree-based style to a stack-based style. So, let’s open up that code base.
— 10:35
This application works exactly as the tree-based one, but at the root of the application we are now using a flat array to model the screens that can be drilled down to. We can see this by trying to alter the entry point to launch the app into a state with some screens already pushed onto the stack: AppView( model: AppModel( path: <#[AppModel.Destination]#>, standupsList: StandupsListModel() ) )
— 10:59
Constructing the root AppModel that powers the entire application now has an optional argument called path that allows you to push on as many screens as you want.
— 11:03
So, to do the equivalent of being drilled down to the standup detail and then the record meeting, we can do it in a flat manner: AppView( model: AppModel( path: [ .detail(StandupDetailModel(standup: .designMock)), .record(RecordMeetingModel(standup: .designMock)), ], standupsList: StandupsListModel() ) )
— 11:19
Now we can clearly see the stack-like structure of navigation in this app. No matter how many layers we need to drill down, we will just append new elements to the end of the array.
— 11:29
If we run the application we will see we are immediately launched into the record feature, and we can even end the meeting to see it was indeed recorded.
— 11:46
So, this style of navigation is also incredibly powerful, but it too has its own unique pros and cons:
— 11:52
One of the biggest pros is that it can handle complex and recursive navigation paths. For example, if it made sense we could easily restore a stack in the application that is drilled down to multiple details and record screens: path: [ .detail(StandupDetailModel(standup: .designMock)), .record(RecordMeetingModel(standup: .designMock)), .detail(StandupDetailModel(standup: .designMock)), .record(RecordMeetingModel(standup: .designMock)), .detail(StandupDetailModel(standup: .designMock)), .record(RecordMeetingModel(standup: .designMock)), ],
— 12:06
Further, each screen in the stack can be fully decoupled from all other screens. It is no longer the case that we need to build the RecordMeeting feature in order to build the StandupDetail feature. We could put each of those features into their own SPM module without any dependency between them, and so that should speed up compile times.
— 12:24
Another big pro is stack-based navigation in SwiftUI has a lot fewer bugs than tree-based. You can typically restore an entire stack of screens from scratch, and SwiftUI seems to just do the right thing, so that’s great.
— 12:34
So, those are some really great pros, but there are some cons.
— 12:38
First of all, this form of navigation is not very precise. You have free rein to create any stack of screens you want, whether it makes sense or not. In fact, the stack we sketched out a moment ago doesn’t make any sense at all because we do not allow the user to navigate to a detail screen from a record screen. But there’s nothing the compiler can do to help prevent us from doing nonsensical things like this: path: [ .record(RecordMeetingModel(standup: .designMock)), ]
— 13:06
If we run this in the simulator we will see that we can’t exit out of the screen, and that’s because the record screen only makes sense if it comes directly after a detail screen so that the newly recording meeting can be attributed to the standup.
— 13:18
So, it is on us to remember these rules and make sure that we never restore a navigation stack that doesn’t make any sense.
— 13:24
Further, while decoupling features is great and comes with improved benefits in compilation times, it’s not without its own costs. When features are fully decoupled it makes it harder to test how they integrate together in practice. For example, now that the StandupDetail screen does not depend on the RecordMeeting screen, we no longer get to fire up the StandupDetail preview to see what happens when we start a meeting and end a meeting. Those two features know nothing about each other, and so tapping the “Start meeting” button can’t do anything meaningful at all.
— 14:05
Instead, we have to back up to the root of the app, run its preview, and then we have to drill down the standup and then further drill down to the record feature. This is extra work you need to do in your previews because the features have been so fully decoupled. Also, if you like to modularize your features and run them in isolated applications, then those apps too will be quite inert. You will only be able to test their functionality, and their functionality alone, but not how they interact with other features.
— 14:38
So, those are the pros and cons of both tree-based and stack-based navigation. One is not universally better or worse than the other, they just each have their own ways in which they shine.
— 14:48
In fact, more often than not you will have a mixture of both in your application. You may have a stack-based navigation system in place at the root of your application, but then each screen in the stack may use tree-based navigation for their destinations.
— 14:59
That is already what is happening inside this version of the Standups application. We can start the application in a state where the detail screen is pushed on the stack, but then further within the detail screen we can show the edit modal using a destination enum: AppView( model: AppModel( path: [ .detail( StandupDetailModel( destination: .edit( StandupFormModel(standup: .designMock) ), standup: .designMock ) ), ], standupsList: StandupsListModel() ) )
— 15:40
If we launch the application we will see we are immediately brought to the edit screen, and any changes we make will be reflected in the detail screen when we dismiss, and further when we pop the detail screen of the stack we also see the changes were made all the way back at the root.
— 15:55
So this shows that both tree-based and stack-based navigation can live in harmony, and honestly we think most applications will probably have both. A counter feature with effects Brandon
— 16:03
Now that we understand how stack-based navigation compares to the types of navigation we have been exploring in this entire series, let’s start to get our hands dirty. Let’s see what it takes to start using a true NavigationStack in a Composable Architecture application, in particular the kind that uses a binding to a collection.
— 16:19
Since the library doesn’t currently offer any native tools for these kinds of navigation stacks, I’m guessing that the process is going to be kind of painful, but it will help us learn what it is we want out of our tools, and then we can concentrate on making those tools.
— 16:30
Now the question is: how are we going to introduce a navigation stack to our application? The inventory app we have been building is quite simple and only has a single screen you can drill down to, so that doesn’t really need the full power of a stack. The tree-based style of navigation we have been using so far seems sufficient.
— 16:45
And on the other hand the Standups app that we built during our Modern SwiftUI series—and just dabbled in a moment ago—could possibly benefit from a stack based navigation scheme since it would allow us to decouple each screen in the stack from each other. That would greatly improve the modularity of the application, but at the same time that application is a little too complex at this moment. Stacks are complex enough on their own to grasp that it would be best to not be mired in additional complexity from the application we are building.
— 17:10
So, that is why when we first covered stack-based navigation in vanilla SwiftUI many months ago we actually scrapped the inventory application and built a fun little toy application in order to explore the many facets of stacks, and we are going to do the same thing now. We are going to show off the kinds of advanced features we want from stacks, such as having many screens with their own isolated, local behavior, while still being able to globally inspect the behavior across all screens at once.
— 17:35
So, let’s get started.
— 17:38
We are going to return one of our all-time favorite toys in the Composable Architecture: the humble counter. But, we are going to add a twist by having an entire navigation stack of counters that run in isolation, and we will even sprinkle in some side effects just to make things a bit more exciting.
— 17:52
We will create a new file, called StackExploration.swift, and we will import the Composable Architecture and create a fresh new conformance to the Reducer protocol: import ComposableArchitecture struct CounterFeature: Reducer { }
— 18:08
The state of the feature will start quite simple. It will just be an integer count: struct CounterFeature: Reducer { struct State: Equatable { var count = 0 } }
— 18:13
We’ve also gone ahead and made it Equatable since we know that helps with observation in the view and testing later on.
— 18:17
Next we will model the actions for the feature, which consists of being able to tap increment and decrement buttons: struct CounterFeature: Reducer { … enum Action { case decrementButtonTapped case incrementButtonTapped } }
— 18:28
And then we can implement the logic of this basic feature by simply incrementing and decrementing the state’s count when the corresponding action is sent into the system: struct CounterFeature: Reducer { … func reduce( into state: inout State, action: Action ) -> Effect<Action> { switch action { case .decrementButtonTapped: state.count -= 1 return .none case .incrementButtonTapped: state.count += 1 return .none } } }
— 18:48
That’s all it takes to the get the basics of the logic for the feature into place. Now we can make the view that displays this information and sends actions into the system. We’ve seen this done over and over in this series, so we are just going to paste in the very basics: import SwiftUI struct CounterView: View { let store: StoreOf<CounterFeature> var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in VStack { HStack { Button("-") { viewStore.send(.decrementButtonTapped) } Text("\(viewStore.count)") Button("+") { viewStore.send(.incrementButtonTapped) } } } .navigationTitle("Counter: \(viewStore.count)") } } }
— 19:26
This is already enough to get a basic preview into place: struct Previews: PreviewProvider { static var previews: some View { CounterView( store: Store( initialState: CounterFeature.State(), reducer: CounterFeature() ) ) .previewDisplayName("Counter") } }
— 19:33
Running this preview we see that counting up and down in the UI does work as we expect.
— 19:39
But of course this demo so far is not impressive at all. We eventually want to get some navigation stack logic into this demo, such as being able to push a new counter onto the stack, but before even doing that let’s make the demo more interesting. Let’s add a button that when tapped it starts a timer that increments the counter with each tick, and if you tap the button again it stops the timer. This will give us an opportunity to explore long living effects and effect cancellation in a stack of features.
— 20:07
So we’ll start by adding some state to track if the timer is active or not: struct State: Equatable { var count = 0 var isTimerOn = false }
— 20:15
And some actions for the new things the user can do in the UI, as well as the new action the timer effect can send back into the system: enum Action: Equatable { case decrementButtonTapped case incrementButtonTapped case timerTick case toggleTimerButtonTapped }
— 20:25
Next we need to implement these actions in the reducer. Let’s start with the simplest, which is .timerTick : case .timerTick: <#code#>
— 20:38
All we want to do here is increment the count by one, and we don’t need to execute any other effects: case .timerTick: state.count += 1 return .none
— 20:45
Next we have the toggleTimerButtonTapped action, which is a lot more interesting: case .toggleTimerButtonTapped: <#???#>
— 20:47
We can start by toggling the isTimerOn boolean to signify that the timer is currently running or stopped: state.isTimerOn.toggle()
— 20:51
And then we need to execute some effects, but which effect will depend on whether or not the timer is on. So let’s start with an if : if state.isTimerOn { // Start up a timer } else { // Stop the timer }
— 21:01
In the true case we will return an effect that represents the timer. It will be long-living and will need to send many actions back into the system, so let’s use Effect.run : return .run { send in }
— 21:16
In here we can simulate a timer using structured concurrency by starting up an infinite loop with a Task.sleep on the inside: return .run { send in while true { try await Task.sleep(for: .seconds(1)) } }
— 21:35
This isn’t the most ideal way to do this. For one, Task.sleep is not a precise tool. The actual amount of time slept can be a little over or under 1 second, and those inaccuracies can build up over time. Another thing is that Task.sleep is a global, uncontrolled dependency, which will make this code hard to test later on.
— 21:54
However, we want to demonstrate that you don’t always need to be steadfast in controlling all your dependencies immediately. If you are in the experimentation phase of your project it is completely fine to use some uncontrolled dependencies, and then eventually you can take control over them once you need to write tests or when it starts affecting your development cycle, such as previews. And luckily the Composable Architecture gives you all the tools necessary to make that transition as painless as possible, and we will be looking at that later.
— 22:20
OK, and with that, after the Task.sleep we can finally send the .timerTick action so that the feature can react to the timer: return .run { send in while true { try await Task.sleep(for: .seconds(1)) await send(.timerTick) } }
— 22:29
The only behavior left to implement is the stopping of the timer. That happens in the else branch of the if we just created, and in order to accomplish this we need to leverage effect cancellation. We can do this by introducing a little private identifier type: private enum CancelID { case timer }
— 22:48
…and then marking the timer effect as being cancellable by that ID: return .run { send in … } .cancellable(id: CancelID.timer)
— 22:51
Then all we need to do is cancel the effect with that ID when the “Stop” button is tapped: } else { return .cancel(id: CancelID.timer) }
— 23:06
That’s all it takes to add the timer behavior to the feature.
— 23:09
Next we will add the button to the view for turning the timer on and off: Button( viewStore.isTimerOn ? "Stop timer" : "Start timer" ) { viewStore.send(.toggleTimerButtonTapped) }
— 23:27
And with that done we can now give the feature a spin in the preview and see that the timer does work exactly as we expect. Stack of counters
— 23:31
So, we now have a decently complex little feature here that does some state mutations and manages some effects. Stephen
— 23:53
Now let’s see what it takes to get an entire stack of these features working so that we can push on as many counters as we want.
— 24:01
We’ll start by modeling a new Composable Architecture feature for handling the logic and behavior of an entire stack of counters: struct RootFeature: Reducer { }
— 24:18
Then we will model the state in this feature. We want some kind of collection of CounterFeature.State ’s so maybe this can just be a plain array: struct State: Equatable { var counters: [CounterFeature.State] = [] }
— 24:37
When the array is empty it represents that we are at the root of the navigation stack, and each time an element is appended to the array we would like the navigation stack to perform a drill-down animation, and when an element is removed it should perform a pop-back animation.
— 24:48
Next we can model the actions that can happen in this stack feature. We will definitely at least have an action at the root of the stack that allows drilling down to a counter: enum Action { case goToCounterButtonTapped }
— 25:06
And then we will also have an action for the child CounterFeature that is embedded into this parent feature: enum Action: Equatable { case counter(CounterFeature.Action) case goToCounterButtonTapped }
— 25:20
However, this can’t be all there is to it. After all, we will potentially have many CounterFeature s pushed onto the stack at once, and so when an action happens in one of the features we need to be able to identify it somehow. Perhaps we can tag each child action with an additional piece of information to differentiate it from the others, such as its index in the array of counters: enum Action: Equatable { case counter(index: Int, action: CounterFeature.Action) case goToCounterButtonTapped }
— 25:48
Next we need to implement the logic and behavior of this root feature. It requires composing two domains together: first the parent domain that implements high-level logic such as appending new counters to the stack, and then the child domain that implements the low-level logic for each counter. For this reason we will use the body style of reducer: var body: some ReducerOf<Self> { }
— 26:12
We can open a Reduce directly inside here to implement the parent logic: var body: some ReducerOf<Self> { Reduce { state, action in } }
— 26:22
And in this Reduce we can implement the very basic parent logic we have right now, which is ignoring any child actions and appending a new counter when the .goToCounterButtonTapped action is sent: Reduce { state, action in switch action { case .counter: return .none case .goToCounterButtonTapped: state.counters.append(CounterFeature.State()) return .none } }
— 26:58
As the parent feature gets more behavior this reducer will become a lot more complicated.
— 27:03
With the basic core logic defined, we can “enhance” it with the behavior of all the child counter features. Whenever a counter action comes in with its index, we’d like to look up the counter state at that index, and run the counter reducer on that state. There is an operator that comes with the Composable Architecture that is specifically tuned for this, and it’s been in the library since the very first day. It’s called forEach , and we can invoke it like so: .forEach( <#WritableKeyPath<_, IdentifiedArray<_>, ElementState>>#>, action: CasePath<_, (_, ElementAction)>, element: <#() -> Reducer#> )
— 27:10
However, right out of the gate we have a problem. The forEach operator that comes with the library forces us to model our collection of state as an IdentifiedArray rather than a plain array. This is something we have discussed in past episodes: it is error prone to model collections of features with plain arrays and to refer to elements using positional indices. This makes it possible for long living effects to accidentally deliver data to the wrong element of the collection, for instance if elements are swapped around, or even deliver data to a non-existent element, for instance if an element is removed, and that would cause a crash.
— 28:06
For this reason, and more, we recommend using “identified” arrays to model collections of features, and this goes for vanilla SwiftUI applications in addition to Composable Architecture applications. However, it just so happens that the Composable Architecture takes a very strong stance on this and requires the use of identified arrays.
— 28:24
So, let’s beef up our domain modeling a bit. We will now make use of a proper identified array: struct State: Equatable { var counters: IdentifiedArrayOf<CounterFeature.State> = [] }
— 28:37
However, that has the knock-on effect of forcing the CounterFeature ’s state to become Identifiable : Type ‘CounterFeature.State’ does not conform to protocol ‘Identifiable’
— 28:43
That’s a little weird. In isolation the CounterFeature doesn’t care at all about being identifiable. It just wants to be a simple feature that models a counter and a timer.
— 28:52
It is not ideal at all, but for now let’s just make CounterFeature.State identifiable just so that we can keep moving forward: struct CounterFeature: Reducer { struct State: Equatable, Identifiable { … } … }
— 29:02
And in order to appease the compiler we need to add an identifier to the State . It’s not really clear what a reasonable ID would be. It certainly can’t be the count because it’s completely legitimate to have multiple counters in a navigation stack with the same count, but using an identified array that identifies via counts would prohibit that.
— 29:20
Well, just to push through and get things compiling we are going to add an auto-generated UUID to the state: import Foundation struct CounterFeature: Reducer { struct State: Equatable, Identifiable { let id = UUID() … } … }
— 29:30
This is not ideal at all . This is of course going to wreak havoc on testing since we are generating a random UUID out of thin air, and it’s just very weird to have to add this detail to the CounterFeature when it shouldn’t need to know it is being presented in a navigation stack at all. Luckily we will be able to make this much nicer soon, but we are going to go with it for now.
— 29:50
With that done we can update the Action enum for RootFeature since we can now tag child actions with the child’s ID: struct RootFeature: Reducer { … enum Action: Equatable { case counter( id: CounterFeature.State.ID, action: CounterFeature.Action ) … } … }
— 30:06
And now we can finally make use of the forEach operator by focusing in on the identified array of counters and the identified counter actions and running the CounterFeature on that subdomain: var body: some ReducerOf<Self> { Reduce { state, action in … } .forEach(\.counters, action: /Action.counter) { CounterFeature() } }
— 30:30
That right there is packing a serious punch. We get a place to implement all of the high-level parent logic and behavior, and then we can instantly integrate the child feature into the parent. In particular, the CounterFeature reducer is run on a specific element of the identified array of counters when a child action comes into the system.
— 30:53
We’re going to be doing a lot more in this reducer soon, but let’s try to get some of the view into place. We will add a new view that holds onto a Store of the RootFeature domain: struct RootView: View { let store: StoreOf<RootFeature> var body: some View { } }
— 30:59
At the root of this view we will install a NavigationStack that will hopefully allow us to push as many counters onto the stack as our heart desires: var body: some View { NavigationStack { } }
— 31:19
At the root of this navigation stack we want a button for pushing on the first counter: var body: some View { NavigationStack { Button("Go to counter") { } } }
— 31:26
But in order to be able to send an action we need to observe the store: var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in NavigationStack { Button("Go to counter") { viewStore.send(.goToCounterButtonTapped) } } } }
— 31:50
We are going to temporarily observe all of the store’s state right now because we’re not sure what exactly needs to be observed. We are still in the exploratory phase, but ideally we will be able to substantially trim down the amount of state we need to observe for this feature.
— 32:05
OK, we are making some progress, but we still don’t have a functional stack feature. There’s still nothing tying the collection of counters in our state to the actual stack of screens presented in the UI. To do that we need to use the initializer of NavigationStack that takes a binding: NavigationStack(path: <#Binding<_>#>) { … }
— 32:26
There are actually two such initializers: One takes a binding of some mutable, random accessible and range replaceable collection of data. An array is the simplest such an example of a collection, but our identified array also fits the bill.
— 32:40
This initializer works by telling SwiftUI what is the current collection of data that represents the stack of screens that should be presented. When a value is added or removed from the array, SwiftUI can see that change and decide if it should push or pop a new screen from the UI. Further, when the user performs an action that is out of our control, such as tapping the “Back” button that is automatically provided by SwiftUI, or swiping from the edge of the screen, SwiftUI will write to the binding with a new collection of data, and that data will have the last element removed.
— 33:09
The other initializer takes a binding of something called a NavigationPath : NavigationStack( path: <#Binding<NavigationPath>#>, ) { … }
— 33:13
NavigationPath is a brand new type in iOS 16 that is a kind of type erased collection of data. It supports some of the operations you expect of a collection, such as being about to count the elements, append new elements to the end, and remove elements from the end. But that’s about it. You can insert or remove elements from anywhere else, and NavigationPath doesn’t actually conform to the Collection protocol so you can’t even iterate over it.
— 33:37
We discussed this type in detail in our episodes on vanilla SwiftUI navigation, but suffice it to say that this type does not play nicely with writing testable and inspectable code. Because the collection is type erased and not iterable, you don’t get an opportunity to do anything with the data once you put it into the path. That means we can’t assert on the data held inside the path, nor can we analyze what’s inside so that we can aggregate or perform logic based on what features are being presented.
— 34:03
For those reasons, and more, we are not going to even attempt to use the NavigationPath initializer, and instead go straight for the type-safe, concrete, collection initializer. To do this we need to provide a binding, and that can be done by deriving a binding from the view store: NavigationStack( path: viewStore.binding( get: <#(RootFeature.State) -> Value#>, send: <#(Value) -> RootFeature.Action#> ) ) { … }
— 34:35
To do that when need to provide the data that powers the binding, which is just the counters identified array: get: \.counters
— 34:42
And then we need to provide a closure that is invoked whenever the binding is written to. Remember that this binding is written to when the user does something in the UI that is out of our control, such as tapping the “Back” button or swiping from the edge of the screen. In such cases the full collection of data is written to the binding, minus that one element that is being removed. So, we need some way to send this full collection of data back to our reducer so that it can update its state.
— 35:07
Sounds like we need another action, which we will call setPath : enum Action: Equatable { … case setPath(IdentifiedArrayOf<CounterFeature.State>) }
— 35:20
And then we will handle it in the reducer by simply replacing our counters array with whatever SwiftUI gives us: case let .setPath(counters): state.counters = counters return .none
— 35:38
Now we have an action to use for our binding: NavigationStack( path: viewStore.binding( get: \.counters, send: RootFeature.Action.setPath ) ) { … }
— 35:40
And Swift still isn’t happy with this, and it isn’t doing a very good job of showing us exactly what is wrong. But we can expand the error in the issue navigator (cmd+5) to see the following: Candidate requires that ‘CounterFeature.State’ conform to ‘Hashable’ (requirement specified as ‘Data.Element’ : ‘Hashable’) (SwiftUI.NavigationStack)
— 36:12
So, it turns out that NavigationStack requires the underlying collection hold onto Hashable elements, which luckily is enough for us to do since CounterFeature.State is easily hashable, and in fact typically most Composable Architecture features have easily hashable state: struct CounterFeature: Reducer { struct State: Hashable, Identifiable { var count = 0 let id = UUID() var isTimerOn = false } … }
— 36:31
With that the project is compiling, and we have now constructed a NavigationStack that is powered by a binding which is linked to our Composable Architecture feature. We are getting very close to a functional toy application.
— 36:48
The main thing missing right now is that we are not currently describing the type of view to push on to the UI when an element is added to the underlying collection. We can see this plain as day if we run the preview, but to do that let’s add the RootView to our preview provider: struct Previews: PreviewProvider { static var previews: some View { RootView( store: Store( initialState: RootFeature.State(), reducer: RootFeature() ) ) .previewDisplayName("RootView") … } }
— 37:11
And now when we tap the “Go to counter” button.
— 37:15
We see that a mostly empty screen with a yellow emoji in the middle is pushed onto the screen.
— 37:19
We can also run this in the simulator by quickly updating the entry point of the app to use the RootView : @main struct InventoryApp: App { var body: some Scene { WindowGroup { RootView( store: Store( initialState: RootFeature.State(), reducer: RootFeature() ) ) } } }
— 37:31
When we try to drill down to the counter we will see the empty screen, and it used to be that some logs were printed to the console to let you know what happened, but that doesn’t seem to be the case anymore for some reason. Maybe that’s just a bug in Xcode 14.3.
— 37:51
The reason this is happening is because describing the binding that controls pushing and popping onto the stack is only half the story when it comes to navigation stacks. The other half to the story is describing the view that should be presented when a new piece of data is pushed onto the stack. To do that we can use the version of the navigationDestination view modifier that takes a type as an argument, rather than the isPresented binding as we’ve seen in past episodes: .navigationDestination( for: <#Hashable.Protocol#>, destination: <#(Hashable) -> View#> )
— 38:32
This view modifier must be applied inside the NavigationStack . It cannot be applied directly to the stack, so we will apply it to the “Go to counter” button.
— 38:38
The for argument is a type, and it must match the type of data that is held in the navigation stack. When SwiftUI sees a new value added to the stack that has a type matching this for argument, it will invoke the trailing closure with that data, and then that is our time to return a view so that it can be pushed onto the screen.
— 39:00
So, the for argument must be CounterFeature.State since that is what is in the collection: .navigationDestination( for: CounterFeature.State.self, destination: <#(Hashable) -> View#> )
— 39:07
If you happen to use the wrong type here then this code will compile just fine, but it will not run correctly in the simulator or preview. It will just be broken, and if you are lucky you may get some warnings printed to the console, though we did just see that’s not always the case.
— 39:26
You might hope that the for argument could automatically be inferred since we are using this inside a NavigationStack that is provided an explicit binding to a concrete collection. Unfortunately that binding and this navigationDestination are completely distinct and don’t know about each other at all, and so we do lose a bit of type safety here which is a bummer.
— 39:43
But with that done we now need to implement the destination closure. This closure will be handed a piece of CounterFeature.State and we need to return a view which will be the thing visually animated onto the screen: .navigationDestination( for: CounterFeature.State.self ) { counterState in <#???#> }
— 39:58
In this closure we want to construct a CounterView , but to do so we somehow need a store of the CounterFeature domain: .navigationDestination( for: CounterFeature.State.self ) { counterState in CounterView(store: <#StoreOf<CounterFeature>#>) }
— 40:04
Before doing that let’s just see that the drill-down does indeed work now that we have navigationDestination in place by just putting any kind of view in this trailing closure: .navigationDestination( for: CounterFeature.State.self ) { counterState in Text("Hello") }
— 40:11
With that bit of test code in place we will see that a drill-down does occur when we tap the button. This is happening because when the button is tapped an action is sent into the system, in the reducer we handle that action by appending an element to the identified array of counters, then the NavigationStack detects that data was appended to its binding collection, it asks navigationDestination for a view corresponding to that piece of data, and then that view is animated onto the screen.
— 40:44
But, how can we now do the correct thing in this trailing closure by constructing a CounterView ? .navigationDestination( for: CounterFeature.State.self ) { counterState in CounterView( store: <#StoreOf<CounterFeature>#> ) }
— 40:52
This view needs a Store focused in on the counter state that was just pushed onto the array. Well, we have a store that holds onto the entire collection of counter states, and we have a single piece of inert CounterFeature.State . Can we somehow combine these things together to get what we need?
— 41:11
Well, since we have a store already in the RootView , let’s try scoping on it to get what we need: CounterView( store: self.store.scope( state: <#(RootFeature.State) -> ChildState#>, action: <#(ChildAction) -> RootFeature.Action#> ) )
— 41:24
How can we transform the full RootFeature.State into the state of just a single CounterFeature ? Well, RootFeature.State holds onto an identified array, and we can easily look up an element in that array using the subscript that takes an ID, and we even have an ID from the counterState argument passed to the closure: self.store.scope( state: { $0.counters[id: counterState.id] }, action: <#(ChildAction) -> RootFeature.Action#> )
— 41:48
So, this just means that if you ever try to read state from this store it will simply look up the state in one of the elements of the counters array. So that seems good.
— 41:58
Next we need a closure that describes how to turn a single CounterFeature.Action into a RootFeature.Action . Luckily we have a case specifically for the child domain, but it just needs an ID: action: { .counter( id: <#CounterFeature.State.ID#>, action: <#CounterFeature.Action#> ) }
— 42:21
The ID can come straight from the bit of counterState we have passed to the destination closure, and the child action is exactly what is handed to this scope closure: self.store.scope( state: { $0.counters[id: counterState.id] }, action: { .counter(id: counterState.id, action: $0) } )
— 42:28
And so we have now scoped our big ole store that holds onto all the parent domain to a store that is focused on the domain of a single counter. So, that seems promising, but we cannot just directly pass it to a CounterView because this store actually holds onto optional state: CounterView( store: self.store.scope( state: { $0.counters[id: counterState.id] }, action: { .counter(id: counterState.id, action: $0) } ) ) Value of optional type ‘CounterFeature.State?’ must be unwrapped to a value of type ‘CounterFeature.State’
— 42:45
This is because subscripting into an identified array with an ID returns an optional since there may not be any element with that ID.
— 42:54
Now technically we can use an IfLetStore view to turn this store of optional state into a store of honest state, but also there’s a simpler path forward since we actually have some non-optional data available immediately. We can just coalesce using the state handed to navigationDestination to turn it into something non-optional: CounterView( store: self.store.scope( state: { $0.counters[id: counterState.id] ?? counterState }, action: { .counter(id: counterState.id, action: $0) } ) )
— 43:14
And now this is compiling.
— 43:15
Now, this technically is not fully correct. With this style in place what will happen is if the counter state is removed from the counters array, then it will fall back onto the counterState handed to navigationDestination , which is only the initial state at the moment the drill-down occurred. So we will see a glitch in the pop animation in which the UI temporarily reverts to a very old version of the state. There are ways to fix this using the returningLastNonNilValue tool we’ve explored in past episodes, but we aren’t going to devote any time to that now, and instead it will just be done properly in the final version of the tools released in the library.
— 43:50
Now that everything is compiling we can give it a spin in the preview. When we run this preview we will see a single button in the middle of the screen that says “Go to counter”, and when tapped a drill-down animation occurs to the counter view, which is totally functional. And we can tap the “Back” button to go back to the root.
— 44:10
Not the most exciting thing in the world, but we are starting to see the beginnings of how the Composable Architecture will interact with NavigationStack . We can even tack on ._printChanges in the preview so that we can see how state changes: reducer: RootFeature()._printChanges()
— 44:28
Now when we tap the root button we see state changes by adding an element to the counters array: received action: RootFeature.Action.goToCounterButtonTapped - RootFeature.State(counters: []) + RootFeature.State( + counters: [ + [0]: CounterFeature.State( + count: 0, + id: UUID( + D5454BBC-871C-43C4-BEA2-070A67D62111 + ), + isTimerOn: false + ) + ] + )
— 44:42
Then tapping the “+” button in the counter view we will see the state inside the first element is updated: received action: RootFeature.Action.counter( id: UUID(867775E9-E879-4C09-9C55-5FF9038243E3), action: .incrementButtonTapped ) RootFeature.State( counters: [ [0]: CounterFeature.State( - count: 1, + count: 2, id: UUID( 867775E9-E879-4C09-9C55-5FF9038243E3 ), isTimerOn: false ) ] )
— 44:55
Even the timer works. We can start the timer, see the count increments every second, which means the effect for the screen is emitting actions that are correctly routed to this exact counter feature. And we can stop the timer.
— 45:11
And tapping the “Back” button sends a setPath action with an empty array, which clears out the counters collection: received action: RootFeature.Action.setPath([]) − RootFeature.State( − counters: [ − [0]: CounterFeature.State( − count: 0, − id: UUID( − D5454BBC-871C-43C4-BEA2-070A67D62111 − ), − isTimerOn: false − ) − ] − ) + RootFeature.State(counters: []) Next time: many destinations
— 45:17
OK, so things are looking pretty great. We now have the beginnings of a stack-based navigation system in a little toy application. We can drill down to a feature, interact with that feature, and then pop back. And most importantly the feature we drill down to is a fully self-contained Composable Architecture feature that manages its own effects, but at the same time the parent can listen for anything happening inside the child.
— 45:38
Brandon : Yeah, and we’re going to need that functionality now because although what we have done so far is looking good, we also can only drill down a single layer. How can we allow drilling down to more layers?
— 45:50
Let’s check that out. 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 0231-composable-navigation-pt10 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 .