EP 233 · Composable Stacks · May 1, 2023 ·Members

Video #233: Composable Stacks: Multiple Destinations

smart_display

Loading stream…

Video #233: Composable Stacks: Multiple Destinations

Episode: Video #233 Date: May 1, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep233-composable-stacks-multiple-destinations

Episode thumbnail

Description

Let’s insert a new feature into the navigation stack. We’ll take things step-by-step, employing an enum to hold multiple features in a single package, and making small changes to how we use our existing APIs before sketching out all-new tools dedicated to stack navigation.

Video

Cloudflare Stream video ID: ffef2d961e45a2591433782d8f7efcc9 Local file: video_233_composable-stacks-multiple-destinations.mp4 *(download with --video 233)*

References

Transcript

0:05

Further, if we had modularized our application and moved the NumberFactFeature , then without a controlled dependency we would have been in quite a tough spot. In order to run the preview in an SPM module it would need the presence of the Info.plist key we just added, but SPM modules don’t have Info.plists. So it would not be possible to apply that fix in order to get our previews working, which means we would be forced to run our feature in a simulator in order to play around with this code, and that completely destroys the fast iterative cycle that Xcode previews are supposed to give us.

0:38

But, with that said, we do have a preview in place, and things are looking good. How do we now make it so that we can drill down to a NumberFactFeature from a CounterFeature ?

0:46

Let’s try that out. Multiple destinations

0:49

Let’s follow the lead the example we set out earlier where we wanted to drill down to a new counter feature. We will try constructing a NavigationLink that takes a value: NavigationLink(value: <#???#>) { Text("Go to fact for \(viewStore.count)") } The question is, what value are we drilling down to?

1:08

We want to go to the NumberFactFeature , so maybe we can just use its state here? NavigationLink( value: NumberFactFeature.State(number: viewStore.count) ) { Text("Go to fact for \(viewStore.count)") }

1:20

Well, to do that we need to make NumberFactFeature.State hashable, which is enough enough so let’s do that: struct NumberFactFeature: Reducer { struct State: Hashable { … } … }

1:37

And in order for that to work we need to make PresentationState conditionally hashable: extension PresentationState: Hashable where State: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(self.value) } }

2:00

…and we need to make AlertAction hashable: enum AlertAction: Hashable {}

2:12

However, when we run the application, and tap on the “Go to fact” button, we will find that we drill-down to a mostly blank screen with a yellow warning sign in the middle. This is letting us know something is wrong, and checking out the logs in the console confirms this: A NavigationLink is presenting a value of type “State” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated. Note: Links search for destinations in any surrounding NavigationStack, then within the same column of a NavigationSplitView.

2:33

The problem here is that we are trying to push some NumberFactFeature.State onto the stack without a matching navigationDestination , so maybe we just need to add another one: .navigationDestination( for: CounterFeature.State.self ) { counterState in … } .navigationDestination( for: NumberFactFeature.State.self ) { numberFactState in … }

3:09

But this leads to just another warning: NavigationLink presenting a value of type “State” targets a stack whose path only accepts values of type “State”. The link cannot be activated.

3:22

While a little confusing without the fully qualified types, this appears to be pointing out that our stack is being powered by a collection of counter state, but we’re attempting to present a number fact.

3:42

That’s the core problem: we are trying to push some NumberFactFeature.State onto the collection that powers the navigation stack, but the navigation stack is powered by something that only understands CounterFeature.State .

3:52

So, what are we to do?

3:54

Well, we can apply some of the lessons we learned when dealing with multiple destinations of sheets, popovers and alerts, and in particular when we finally decided to improve the correctness of our code by modeling everything with a single enum.

4:08

We need to beef up our identified array so that it doesn’t just hold onto a concrete array of CounterFeature.State , but instead holds onto an enum, which in turn has a case for each kind of feature that can be pushed onto the stack.

4:20

So, let’s sketch out such an enum: enum Path { case counter(CounterFeature.State) case numberFact(NumberFactFeature.State) }

4:42

I’m going to nest the enum directly inside the StackFeature which will help prevent this Path type from conflicting with other Path types, such as in SwiftUI. Also we are naming it Path to kind of mimic the naming that SwiftUI uses for the naming of the binding argument on NavigationStack .

5:01

So, what if we could refactor our StackFeature.State to hold onto an identified array of Path values instead of counter state: struct State: Equatable { var path: IdentifiedArrayOf<Path> = [] }

5:13

Then we could represent being drilled down to many counters with a number fact at the end like this: path: [ .counter(…), .counter(…), .counter(…), .numberFact(…), ]

5:29

Well, before we can do that we are going to have some compiler errors to fix. First off all we need Path to be Equatable and Identifiable : enum Path: Equatable, Identifiable { … }

5:51

The automatically synthesized Equatable conformance works just fine, but we are now forced to provide an explicit Identifiable conformance. This is something we ran into a few episodes back when we embraced destination enums. And just like in that situation, in the long run we are not going to need to provide explicit conformances. All of these details can be hidden away by the library, but we are going to dive into those details just yet. For now we will just make Path identifiable by calling out to id in each case: enum Path: Equatable, Identifiable { … var id: AnyHashable { switch self { case let .counter(state): return state.id case let .numberFact(state): return state.id } } }

6:52

But in order for that to work we now need to even make NumberFactFeature.State identifiable, which we will do in the simplest way possible: by just cramming a randomly generated UUID into the state: struct NumberFactFeature: Reducer { struct State: Hashable, Identifiable { let id = UUID() … } … }

7:11

This guarantees that when some NumberFactFeature.State is created for the first time it gets a unique identifier. This is also what we did for CounterFeature too, and we want to make sure everyone knows that we do not consider this ideal in anyway, but we are not yet ready to address this problem.

7:28

OK, now our Path type is equatable and identifiable, which means it is fine to define a path identified collection in our feature’s state. There’s still a lot of other compiler errors to tend to, but before we can truly fix those problems we need to fix our actions. Because now Path correctly describes all the pieces of state that can be pushed onto the stack, but we only have CounterFeature actions modeled in the domain: enum Action: Equatable { case counter( id: CounterFeature.State.ID, action: CounterFeature.Action ) … }

7:53

We need a place to deliver number fact actions too. I guess we could just add another case: enum Action: Equatable { case counter( id: CounterFeature.State.ID, action: CounterFeature.Action ) case numberFact( id: NumberFactFeature.State.ID, action: NumberFactFeature.Action ) … }

8:12

That seems a little weird to enlarge the StackFeature ’s domain directly for actions when we didn’t do the same for state. What if instead we had an enum of all the features’ actions that we can navigate to: enum PathAction: Equatable { case counter(CounterFeature.Action) case numberFact(NumberFactFeature.Action) }

8:44

And then we will have one single case in the StackFeature.Action to hold onto this enum of path actions: enum Action: Equatable { case path(id: Path.ID, action: PathAction) … }

9:09

But at this point we have implemented half the requirements needed for a reducer: we have a state type and action type. So, why not bundle it all into its own Path reducer: struct Path: Reducer { enum State: Hashable, Identifiable { case counter(CounterFeature.State) case numberFact(NumberFactFeature.State) var id: AnyHashable { switch self { case let .counter(state): return state.id case let .numberFact(state): return state.id } } } enum Action: Equatable { case counter(CounterFeature.Action) case numberFact(NumberFactFeature.Action) } }

9:33

And then in the body of this new reducer we can localize all of the logic for each feature that can be navigated to, just as we did with the Destination reducer in past episodes: var body: some ReducerOf<Self> { Scope(state: /State.counter, action: /Action.counter) { CounterFeature() } Scope( state: /State.numberFact, action: /Action.numberFact ) { NumberFactFeature() } }

10:07

We now have a single domain packaged up that is capable of describing all the different features that we can drill-down to, and this type looks exactly like the Destination reducer we created for the inventory app, except it served to package up all the sheet, popover and alert destinations into a single package.

10:45

Let’s see what it takes to get everything compiling.

10:48

First, we RootFeature.State that holds onto an identified array of Path.State : struct State: Equatable { var path: IdentifiedArrayOf<Path.State> = [] }

10:52

Then we have the path and setPath actions that need to deal with the Path reducer state and actions now: enum Action: Equatable { case path(id: Path.State.ID, action: Path.Action) case setPath(IdentifiedArrayOf<Path.State>) … }

11:01

And now the main error we are seeing now is that at the bottom of the StackFeature reducer it is no longer correct to invoke forEach by focusing in on the counter domain. Instead we need to focus it in on the path domain: var body: some ReducerOf<Self> { Reduce { state, action in … } .forEach(\.path, action: /Action.path) { Path() } }

11:14

That right there integrates all of the logic for every feature in the path with the main, core logic of the feature.

11:32

Now we are seeing a lot more compiler errors in the core reducer since Swift can now better type check this expression. For example, we are currently destructuring the counter action to detect when the delegate goToCounter action is sent: case let .counter( id: _, action: .delegate(.goToCounter(count)) ):

11:36

This now changes to be a little more nested in order to first specify that we want to destructure path actions, and then to further specify that we want to destructure a counter action inside the path: case let .path( id: _, action: .counter(.delegate(.goToCounter(count))) ):

12:06

Then, once inside that case we can no longer append some CounterFeature.State directly to the counters array: state.counters.append(CounterFeature.State(count: count))

12:15

…but instead we must append to the path and further wrap the state in the counter case: state.path.append( .counter(CounterFeature.State(count: count)) )

12:27

Then, in order to ignore all the other actions for the path features we will destructure .path : case .path: return .none

12:35

And finally in the reducer there is the part where we are appending the first piece of counter state to the stack, which again we need to wrap in the counter case: case .goToCounterButtonTapped: state.path.append(.counter(CounterFeature.State())) return .none

12:41

And the last compiler error in the reducer is for the .setPath action: case let .setPath(path): state.path = path return .none

13:02

This now completely fixes the reducer, and we just have a few problems left to fix in the view.

13:07

First we need to update the creation of the NavigationStack to derive a binding to the path instead of counters : NavigationStack( path: viewStore.binding( get: \.path, send: StackFeature.Action.setPath ) ) { … }

13:17

Next, the binding requires the element be hashable, so we need to add a conformance to Path.State . struct Path: Reducer { enum State: Hashable, Identifiable { … } … }

13:39

Next we need to update the navigationDestination view modifier. It will now listen for changes to the path, which means it will handle Path.State : .navigationDestination(for: RootFeature.Path.State.self) {

13:59

So, whenever a new path value is appended to the stack, this closure will be invoked, and that is our opportunity to turn a simple piece of data into a full SwiftUI view, which is the thing that will be visually pushed onto the screen.

14:12

Now path is currently just plain data, and all of our views take Store s of feature domains, since that is the thing that encapsulates a feature’s full logic and behavior, including effects. Also, this closure is only invoked at the moment the path changes, and never again. So the data will even be stale later on as the user performs actions to mutate state.

14:41

So, we need to transform this inert state into an actual Store which can power our child features. We can do this similar to how we did it before, using IfLetStore , but first we need to switch on the path so that we can figure out which child feature is being pushed onto the stack: .navigationDestination(for: RootFeature.Path.State.self) { switch $0 { case let .counter(counterState): EmptyView() case let .numberFact(numberFactState): EmptyView() } }

15:27

Once we get into one of these cases we need to create the corresponding view, either CounterView or NumberFactView , and we can use similar tricks as earlier in order to provide those views with stores of the correct shape. We just need to do some spelunking through more layers of data types.

15:51

For example, to construct a CounterView we need to construct a store focused on the counter domain that was just pushed onto the stack: case let .counter(counterState): CounterView(store: <#StoreOf<CounterFeature>#>)

15:56

The only store we have at our disposable is focused on the entire RootFeature , so we need to somehow scope it down to something smaller: CounterView( store: self.store.scope( state: <#(RootFeature.State) -> ChildState#>, action: <#(ChildAction) -> RootFeature.Action#> ) )

16:07

The state transformation needs to somehow take the entirety of RootFeature.State and extract out CounterFeature.State . Well, we have an ID for the counter feature we would like to extract, so we can start there: state: { $0.path[id: counterState.id] },

16:32

However, this can’t be right because we are subscripting into path which gives us some Path.State , which is an enum of all the possible places we can navigate to in the stack. We need to further destructure on this value to only match when the state matches the counter case: state: { guard case let .counter(state) = $0.path[id: counterState.id] else { return <#???#> } return <#???#> },

16:50

If the case matches we can return that state, and if it doesn’t we can default to the counterState we already destructured: state: { guard case let .counter(state) = $0.path[id: counterState.id] else { return counterState } return state },

17:21

That takes care of the state transformation.

17:25

The action transformation needs to go in the opposite direction. Given a very local counter action from just one specific counter in the entire stack, we need to somehow bundle that up into a RootFeature.Action . We know such an action will be put into the .path case so we can start there: action: { .path(id: <#AnyHashable#>, action: <#RootFeature.Path.Action#>) }

17:45

The ID can come from the counterState that we have already destructured from the path: action: { .path( id: counterState.id, action: <#RootFeature.Path.Action#> ) }

17:51

And the child action will be of the counter case with the argument bundled up: action: { .path(id: counterState.id, action: .counter($0)) }

18:13

The .numberFact case can be handled in basically the same way. We can even copy-and-paste the counter case and just make a few small changes: case let .numberFact(numberFactState): NumberFactView( store: self.store.scope( state: { guard case let .numberFact(state) = $0.path[id: numberFactState.id] else { return numberFactState } return state }, action: { .path( id: numberFactState.id, action: .numberFact($0) ) } ) )

18:35

And while things are extremely messy, the view is at least in compiling order.

18:41

In fact, the only error we have in the entire app is at the entry point, where we wanted to deep link into a state of the application where multiple counters are pushed onto the stack. We just need to wrap each of those CounterFeature.State values in a .counter case: StackView( store: Store( initialState: CounterStackFeature.State( path: [ .counter(CounterFeature.State(count: 42)), .counter(CounterFeature.State(count: 1729)), .counter(CounterFeature.State(count: -999)), ] ), reducer: CounterStackFeature()._printChanges() ) )

18:58

If we run the application we will see we are drilled down 3 layers.

19:07

However, there are 2 small problems that must be fixed, and unfortunately the compiler cannot detect them for us. It is on us to know to make this update. If we tap on the “Push counter” button or “Go to fact” button, we will see that nothing happens.

19:33

The problem is that we have a navigation link that use the .init(value:) initializer and is using CounterFeature.State directly: NavigationLink( value: CounterFeature.State(count: viewStore.count) ) { Text("Push counter: \(viewStore.count)") }

19:46

The NavigationStack no longer speaks the language of CounterFeature.State , but rather Path.State , which is the enum with a case for every type of screen that can be pushed onto the stack.

19:51

So, this now needs to further wrap the state in some Path.State : NavigationLink( value: RootFeature.Path.State.counter( CounterFeature.State(count: viewStore.count) ) ) { Text("Push counter: \(viewStore.count)") }

20:07

It is verbose, but that is the cost of using this simpler style of API from the view and wanting to keep your domain as concisely modeled as possible.

20:24

Just below that NavigationLink we have another one that is using the same initializer, but this time it is the theoretical one we put in to see how we could navigate to the NumberFactFeature . Now we need to wrap up the NumberFactFeature.State in the Path.State enum: NavigationLink( value: RootFeature.Path.State.numberFact( NumberFactFeature.State(number: viewStore.count) ) ) { Text("Go to fact for \(viewStore.count)") }

20:42

And that’s all it takes to get everything working. We can now drill down into as many counter features as we want, and we can drill down into a number fact feature. We can even tap the “Get fact” button and we will see an alert appears.

21:06

We can even open the logs to see exactly what happened when we tapped that button: received action: CounterStackFeature.Action.path( id: UUID(F7CD557C-E406-4A5C-89EA-0630D8FED3A2), action: .numberFact(.factButtonTapped) ) (No state changes) received action: CounterStackFeature.Action.path( id: UUID(F7CD557C-E406-4A5C-89EA-0630D8FED3A2), action: .numberFact( .factResponse( .success( """ -999 is a number for which we're \ missing a fact (submit one to \ numbersapi at google mail!). """ ) ) ) ) CounterStackFeature.State( path: [ … (3 unchanged), [3]: .numberFact( NumberFactFeature.State( - _alert: nil, + _alert: AlertState( + title: """ + -999 is a number for which we're \ + missing a fact (submit one to \ + numbersapi at google mail!). + """ + ), id: UUID( F7CD557C-E406-4A5C-89EA-0630D8FED3A2 ), number: -999 ) ) ] ) We can see that the .factButtonTapped action was sent in the .numberFact feature, but no changes were directly made. Instead an effect was started that ultimately fed back into the system, which is the .factResponse action, and that caused the last element of the path to updated by populating the alert state.

22:14

Note that it is quite nice that the Composable Architecture does extra work to collapse data that has not changed, such as the first 3 elements of the path collection. It simply says “… (3 unchanged),” and so that keeps our logs nice and tidy. Sketching out a better way

22:44

OK, so we now have a decently complex toy application built in the Composable Architecture and using iOS 16’s new NavigationStack API, in particular the initializer that takes a binding. And the steps to make use of the binding initializer are quite reminiscent of what we did for tree-based navigation in earlier episodes.

23:03

We start by modeling the domain of all the places we can navigate to, in particular we defined a dedicated Path reducer conformance that glued together all the logic and behavior of the features we want to be able to navigate to in the stack. This included the counter feature and the number fact feature. That process was identical to what we did for tree-based navigation.

23:23

Then, we integrated that Path reducer into the parent feature that holds onto the stack by using the forEach reducer operator, which has been in the library since the very first day. And that process was very similar to what we did with tree-based navigation, except we used the forEach operator instead of the ifLet operator.

23:38

And then over in the view we made use of the other navigationDestination view modifier, the one that specifies the type of data held in the stack’s collection, as opposed to the one that took an isPresented binding which is what we used in tree-based navigation. In tree-based navigation we would have used the navigationDestination view modifier for each type of screen we wanted to navigate to. But for stack-based navigation we use it just a single time, and in the trailing closure we describe all the different views that can be navigated to. Stephen

24:04

So, things are looking great, but also the code we had to write to get this point is quite ad hoc and messy, especially in the view. It would be far better if we could provide some tools in the library that make it easier to construct the NavigationStack as well as the navigation destinations. So let’s give that a shot.

24:22

Let’s first look at how the NavigationStack is constructed right at the root of our view: var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in NavigationStack( path: viewStore.binding( get: \.path, send: CounterStackFeature.Action.setPath ) ) { … } } }

24:27

We are currently observing all of the store’s state just so that we can get a binding to the path. That means every single little change inside the root feature is going to cause this entire view to recompute. This even includes if there is some small change inside an element in the stack, even though as far as the NavigationStack is concerned the only thing it really cares about is when items are added or removed to the stack. It does not care at all about what happens inside individual elements of the stack.

24:53

So, this is inefficient, and also this is just a lot of manual work to perform to get right. We need to construct the WithViewStore and then construct the binding. What if instead we had a custom initializer on NavigationStack that allowed us to pass a store that is focused on the path domain, and it could do the rest: NavigationStack( store: self.store.scope( state: \.path, action: CounterStackFeature.Action.path ) ) { … }

25:41

This initializer could responsible for observing the bare minimum to get the job done. In particular, it doesn’t need to observe everything happening in the path, but rather only when elements are added or removed. That would be more efficient, and would make it simpler to construct navigation stacks.

25:58

However, as we saw with NavigationLink , we aren’t actually going to be able to implement this initializer. With links we had to create a dedicated NavigationLinkStore type in order to properly implement the logic, and we will have to do the same here. Perhaps it can be called NavigationStackStore : NavigationStackStore( self.store.scope( state: \.path, action: CounterStackFeature.Action.setPath ) ) { … }

26:16

Next, let’s take a look at our use of the navigationDestination view modifier: .navigationDestination( for: RootFeature.Path.State.self ) { path in switch path { case let .counter(counterState): CounterView( store: self.store.scope( state: { guard case let .counter(state) = $0.path[id: counterState.id] else { return counterState } return state }, action: { .path(id: counterState.id, action: .counter($0)) } ) ) case let .numberFact(numberFactState): NumberFactView( store: self.store.scope( state: { guard case let .numberFact(state) = $0.path[id: numberFactState.id] else { return numberFactState } return state }, action: { .path( id: numberFactState.id, action: .numberFact($0) ) } ) ) } }

26:22

This is pretty gnarly, and we of course would never want to write code like this. Each new type of feature that can be pushed onto the stack incurs 10 whole new lines of code.

26:34

Let’s theorize a better syntax for this.

26:37

First off all, since we are creating our own ideal syntax from scratch, perhaps we can do a little better than having NavigationStack and navigationDestination be two completely separate, far apart APIs. That gives us an opportunity for a type mismatch between the type of data the NavigationStack understands and the kind of data navigationDestination listens for.

27:01

What if instead the very act of constructing a navigation stack required you to provide an additional trailing closure for describing all of the destination views: NavigationStackStore( store: self.store.scope( state: \.path, action: CounterStackFeature.Action.setPath ) ) { … } destination: { … }

27:09

Further, what if there was a much simpler way to describe all of the possible views that could be navigated to in this trailing closure. What if we could simply describe the case we want to focus in on for the destination view as well as the view itself.

27:23

In fact, there’s already a tool that comes with the library that does something like this, and it is called CaseLet : } destination: { CaseLet( state: /RootFeature.Path.State.counter, action: RootFeature.Path.Action.counter, then: CounterView.init(store:) ) CaseLet( state: /RootFeature.Path.State.numberFact, action: RootFeature.Path.Action.numberFact, then: NumberFactView.init(store:) ) }

28:18

And what if we could prove to the compiler, exhaustively, that we are handling each case by passing the initial state along: } destination: { state in switch state { case .counter: CaseLet( state: /RootFeature.Path.State.counter, action: RootFeature.Path.Action.counter, then: CounterView.init(store:) ) case .numberFact: CaseLet( state: /RootFeature.Path.State.numberFact, action: RootFeature.Path.Action.numberFact, then: NumberFactView.init(store:) ) } }

28:39

However, the CaseLet view is only meant to be used inside the SwitchStore view that the library provides. The way this works is that the SwitchStore implicitly passes a store to each CaseLet view via a SwiftUI environment object, and then the CaseLet views do further transformations on that store. That little sneaky communication is what allows this syntax to be so succinct.

29:01

But, although it is succinct, it is also true that every use of NavigationStackStore is going to have to wrap all of its destinations in a SwitchStore . Perhaps we can hide that extra layer of nesting in the NavigationStackStore directly so that we can omit it entirely, and instead the destination trailing closure will be provided the piece of state that can be switched on.

29:13

That would be really great.

29:22

This even starting to look very similar to what we did for the sheet , popover and fullscreenCover view modifiers that accepted a store. We created an overload that took a store focused in on the enum domain, and then further provided transformations to focus in on a single case of the enum domain. Next time: improving ergonomics

29:40

So, we can clearly see that trying to make use of navigation stacks using only the tools that the Composable Architecture gives you access to today is not going to cut it. There is a lot of verbose and ugly code that needs to be written, and there is a lot of room to make things safer and more concise. Brandon

29:55

Let’s start building the API of our dreams for the navigation stack. We will be able to take a lot of inspiration from how we approached this problem for PresentationState and PresentationAction , which worked great for tree-based navigation, but we are also going to have to wade into uncharted territory to extend the ideas to stacks.

30:14

So let’s get started…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 0233-composable-navigation-pt12 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 .