Video #232: Composable Stacks: Multiple Layers
Episode: Video #232 Date: Apr 24, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep232-composable-stacks-multiple-layers

Description
We enhance our navigation stack with a bit more complexity by adding the ability to drill down multiple layers in multiple ways: using the new navigation link API, and programmatically. We also prepare a new feature to add to the stack.
Video
Cloudflare Stream video ID: 300191b73f30b604b70b6485eaa87b0f Local file: video_232_composable-stacks-multiple-layers.mp4 *(download with --video 232)*
References
- Discussions
- Composable navigation beta GitHub discussion
- 0232-composable-navigation-pt11
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
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. Brandon
— 0:26
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?
— 0:38
Let’s check that out. Drilling down more layers
— 0:40
We’ve already seen one way to push a new screen onto the stack, and that is by sending an action into the store, interpreting that action in the reducer, and appending some data onto the collection that powers the navigation stack. This is exactly what we did at the root of the stack when the “Go to counter” button was tapped: case .goToCounterButtonTapped: state.counters.append(CounterFeature.State()) return .none
— 0:58
However, this is not always straightforward to do. If we want to drill down to another counter feature from within the counter feature, then we cannot simply append values to this counters array, because we don’t even have access to the counters array. We have no way whatsoever to append values to the array, and that’s actually a good thing because it means our CounterFeature is decoupled from the RootFeature , allowing us to theoretically compile it in isolation without building everything else that can be navigated to.
— 1:34
So, what are we to do? Well, there is actually another way to push data onto the stack, and it’s even the simplest way. There is a new NavigationLink initializer that allows you to associate a bit of data with the link, and when the user taps that link, if the type of that data matches the type of data held in the navigation stack, then it will be appended to the end of the stack.
— 1:56
Let’s give it a shot. We will add a NavigationLink to the counter feature in order to drill down to another counter feature that starts at the same count as the previous counter. So, we can try using the new initializer that takes a value: NavigationLink(value: <#Hashable?#>, label: <#() -> Label#>)
— 2:14
The value we want to associate is a piece of CounterFeature.State since that is exactly the type of element our identified array holds, and we can get the current count from the view store: NavigationLink( value: CounterFeature.State(count: viewStore.count) ) { }
— 2:32
And then we will add a label to the link: NavigationLink( value: CounterFeature.State(count: viewStore.count) ) { Text("Push counter: \(viewStore.count)") }
— 2:40
We can now run the application and we will see that we can drill down any number of layers we want, so that’s great.
— 2:51
We can even start a timer, push on a new counter, and we will even see the title of the previous screen updating! So the timer in the previous screen is still going. We can even start a whole bunch of timers, and if we check out the logs we will see that each effect is delivering is properly delivering its data to the correct screen.
— 3:07
So, that’s pretty cool. We also get a very simple way to perform deep-linking into any number of layers we want. For example, we can change the entry point of the application so that we are drilled down 3 layers deep: CounterStackView( store: Store( initialState: CounterStackFeature.State( counters: [ CounterFeature.State(count: 42), CounterFeature.State(count: 1729), CounterFeature.State(count: -999), ] ), reducer: CounterStackFeature()._printChanges() ) )
— 3:32
Launching the simulator shows we are indeed 3 layers deep, and we can interact with each layer or pop all the way back to the root.
— 3:45
It is interesting to also take a look at how the “Counter” preview operates. If we switch to it we will see the link is actually greyed out. That is because the preview is not wrapped in a NavigationStack , and that’s because there isn’t any navigation stack functionality for us to make use of. All of that logic and behavior is in the parent. But, let’s go ahead and wrap the preview in NavigationStack just to see what happens: NavigationStack { CounterView( store: Store( initialState: CounterFeature.State(), reducer: CounterFeature() ) ) } .previewDisplayName("Counter")
— 4:23
Now the link is active, but tapping on it does nothing. That’s because tapping the link makes SwiftUI try to append the link’s value to the collection of data powering the stack, but there is no collection of data. We aren’t providing a Binding to the initializer, and so there is nothing SwiftUI can do.
— 4:43
This is showing one of the cons to stack-based navigation we discussed last episode. While it is powerful to decouple all the destinations in a navigation stack so that they can be compiled in isolation, it does mean that those destinations largely become inert on their own. We can’t test the functionality of drilling down to another counter feature unless we run the full RootView preview, and that’s only possible if we can compile the entire root feature. So, it’s just something to keep in mind.
— 5:14
So, we can now push many screens onto the stack, but also this form of pushing a feature onto the screen is very, very basic. There’s just a button and when it is tapped it causes a drill down. You do not get any ability to intercept that user action of tapping on the link so that you could perform some custom logic, or execute some effects, such as track analytics events, or maybe you even want to prevent the drill down from happening based on some validation logic.
— 5:52
We believe those situations are extremely common, and maybe even the majority use case, yet if you want to do any of that you unfortunately cannot use this initializer of NavigationLink at all. SwiftUI does not give you the opportunity to customize this interaction whatsoever. All that can happen is that the user taps the link, the value is appended to the collection powering the stack, and the drill-down animation occurs. But you cannot layer on additional logic to that event at all, and this goes for vanilla SwiftUI as well as the Composable Architecture.
— 6:21
So, if you do need that additional logic, you have to go back to using plain Button s and sending actions back into the view store. Let’s give that a shot.
— 6:28
I’m going to add a new button that when pressed simulates some effectual work being done, during which a loading indicator will be shown, and then when the work completes we will finally drill down into the counter screen.
— 6:42
It’s going to take a few steps to complete this. First we will add some new state to the CounterFeature because we now need a boolean that indicates whether or not that effectual work is being done: struct State: Hashable, Identifiable { … var isLoading = false }
— 6:54
Next we have some actions to add to the domain too. There’s the action that is sent when the button is tapped, and we will just be very explicit about that right now: enum Action: Equatable { … case loadAndGoToCounterButtonTapped … }
— 7:05
Then we will have an action to feed into the system from the loading effect: enum Action: Equatable { … case loadResponse … }
— 7:11
Then in the reducer we need to handle these new actions. For the loadAndGoToCounterButtonTapped action we can flip the isLoading state to true and fire off an effect that waits a little bit of time before emitting the loadResponse action: case .loadAndGoToCounterButtonTapped: state.isLoading = true return .run { send in try await Task.sleep(for: .seconds(2)) await send(.loadResponse) }
— 7:41
Then in the loadResponse we will flip the isLoading state back to false : case .loadResponse: state.isLoading = false return .none
— 7:52
We also want to push a new counter feature onto the stack when this action is sent, but we don’t have access to that state right now, so I guess there’s nothing else we can do in this reducer.
— 8:05
But, let’s move onto the view to add a button to the CounterView that sends the loadAndGoToCounterButtonTapped action when tapped, and shows a progress view when the isLoading is true: Button { viewStore.send(.loadAndGoToCounterButtonTapped) } label: { HStack { if viewStore.isLoading { ProgressView() } Text("Load and go to counter: \(viewStore.count)") } }
— 8:33
That’s enough to get something running a preview, and we can tap the “Load and go to counter” button, see the progress indicator appear and disappear, but of course nothing else happens. We don’t actually see a new counter feature pushed onto the screen.
— 8:49
This is because no data is being appended to the array that powers the navigation stack. Only the parent feature, which is the RootFeature reducer, can do that. So, I guess we are going to need the RootFeature reducer to listen for this action and layer on some additional logic.
— 9:20
However, as we’ve seen in previous episodes, it is not a good idea to have parent reducers listening in on just any child action. It is far better if there is a clear signal sent from the child that there is an action it wants the parent to react to. Previously we accomplished this with what we called “delegate” actions. These were actions that the child feature sends only to communicate to the parent.
— 9:49
For example, the FirstTabFeature has a delegate action specifically for telling the parent when it should switch to the inventory tab: struct FirstTabFeature: Reducer { … enum Action: Equatable { case goToInventoryButtonTapped case delegate(Delegate) enum Delegate: Equatable { case switchToInventoryTab } } Then, when the child wants to tell the parent to go to the inventory tab, it just sends this delegate action: case .goToInventoryButtonTapped: return .send(.delegate(.switchToInventoryTab))
— 10:03
And the parent feature, which is the AppFeature reducer, will listen for this action and perform the necessary logic to switch to the inventory tab: case let .firstTab(.delegate(action)): switch action { case .switchToInventoryTab: state.selectedTab = .inventory return .none }
— 10:13
This is a much better way to facilitate child-to-parent communication than the parent simply snooping on whatever action it wants to in the child domain.
— 10:22
So, let’s do something similar here. The CounterFeature will now have a delegate action that tells the parent it wants to go to a new CounterFeature : enum Action: Equatable { … case delegate(Delegate) … enum Delegate: Equatable { case goToCounter(Int) } }
— 10:37
And we are even holding onto some associated data for the delegate action so that we can tell the parent which count to drill-down to. That way the parent doesn’t even have to think about it, or search through the state to figure out what number should be pushed on the stack. It can just be handed directly to the parent.
— 10:55
Now when we handle the .loadResponse action we will also send this delegate action so that the parent can intercept it: case .loadResponse: state.isLoading = false return .send(.delegate(.goToCounter(state.count)))
— 11:29
And the way the parent intercepts this action is to destructure the counter case, which holds onto an identified CounterFeature.Action , and then further destructure that action to handle the .delegate(.goToCounter) action: case let .counter( id: _, action: .delegate(.goToCounter(count)) ): state.counters.append(CounterFeature.State(count: count)) return .none
— 12:06
That’s all we need to get the child domain to communicate to the parent domain, and we can now confirm that the feature works as we intend. You can tap the “Load and go to counter” button, a progress view appeared for a few seconds, and then the drill down happens.
— 12:19
So, this is really cool. And now we have the ability to layer on additional logic. When we get the response from the effect we tell the parent it’s now time to go to the counter feature, but we could do validation logic, track analytics, or anything.
— 12:41
For example, we could randomly control whether or not the parent goes to the counter feature: if Bool.random() { return .send(.delegate(.goToCounter(state.count))) } else { return .none }
— 12:52
Half the time, when you tap the button and it starts loading, it’ll simulate a failure and won’t trigger the drill-down at all. Number fact feature
— 13:32
So, we now have a firm foot in the world of stack-based navigation. We are clearly seeing that navigation in this toy application is driven by a flat collection of data rather than a deeply nested tree, and the simple act of pushing to and popping from that data causes SwiftUI to animate drill-downs and pop-backs.
— 13:51
And while this toy example does model some interesting real world scenarios, such as effects in the child domain and communication between child and parent, there is one gigantic simplification we are making: there is only one kind of screen in the stack. All we can do is navigate to another counter feature. But what if we had other features we wanted to navigate to? Stephen
— 14:10
Let’s see what it takes to introduce a little variety into the navigation stack by introducing a new feature. We will make a “number fact” feature, which allows us to make an API request for fetching a fact about whatever the current count is. That will give us an opportunity to play around with a new type of effect, and see what it takes to get a new type of destination into the root stack.
— 14:29
So, let’s give it a shot.
— 14:31
Let’s start by quickly sketching out a new feature to use in the stack. It will be called NumberFactFeature , and its sole purpose is to display a static number and expose a button that when tapped will fetch a random fact for that number and display the fact in an alert.
— 14:50
The domain can be quite simple. It will just need to hold onto the number being displayed, and it can even be a let since we don’t plan on ever changing it, and we need some alert state: struct NumberFactFeature: Reducer { struct State: Equatable { var alert: AlertState<<#???#>>? let number: Int } }
— 15:10
We have to specify what kind of actions can occur in the presented alert, but right now we have no such actions. We just want to show the fact alert and then allow the user to dismiss. So we can use an empty enum for now: struct NumberFactFeature: Reducer { struct State: Equatable { var alert: AlertState<AlertAction>? … } enum AlertAction: Equatable {} }
— 15:31
…and in the future if we want to add more actions to the alert we will easily be able to.
— 15:37
Also, while we are here let’s go ahead and start making use of some of the presentation machinery we have built in past episodes. We will use the @PresentationState property wrapper so that it can easily be used with our new ifLet operator for composing parent and child domains. @PresentationState var alert: AlertState<AlertAction>?
— 16:00
For the actions of the domain we have the obvious one of the user tapping on a button to get the fact, but then we have some non-obvious ones such as the response we get back from the effect that loads the fact from the network. We will represent that action with a TaskResult , which is our equatability-friendly way of modeling results with errors in the Composable Architecture: enum Action: Equatable { case factButtonTapped case factResponse(TaskResult<String>) }
— 16:39
There is yet another sneaky action we need to model, which is the alert actions. Now we just said that we specifically will not have any alerts actions. Well, that is except for the action of dismissing the alert, which we could model explicitly like so: enum AlertAction: Equatable { case dismiss }
— 17:00
But also, in past episodes we have built first class tools for the Composable Architecture that makes these kinds of things nicer. We will go ahead and fully model the alert’s actions as a PresentationAction : case alert(PresentationAction<AlertAction>)
— 17:23
With the domain modeled we can move onto the logic and behavior of the feature. We will implement this feature using the body property of the Reducer protocol because we will be composing features together. We will have the core logic of the feature, and then we will enhance it with the ifLet operator in order to handle all of the alert logic: var body: some ReducerOf<Self> { Reduce { state, action in switch action { } } .ifLet(\.$alert, action: /Action.alert) }
— 18:06
Now we can start filling in the core logic of the feature. For example, when the alert sends an action we know it can only be the dismiss action, and so we can just ignore it: case .alert: return .none
— 18:19
Next we can handle the factButtonTapped action in order to execute a network request for loading a fact, and then sending it back into the system with the factResponse action.
— 18:27
We know we want to return an effect that emits a single action, so we can use Effect.task : case .factButtonTapped: return .task { }
— 18:39
Then in here we want to perform a network request. Now ideally we would design a proper dependency that abstracts away making the request and inject that dependency into this feature using the @Dependency property wrapper. That would make it easy to control this dependency for tests, previews and comes with all types of other benefits.
— 18:56
However, we will not actually be doing that right now. We are still in the experimenting phase, and don’t want to too quickly commit to anything, so I am actually going to just make use of URLSession directly in the reducer’s effect.
— 19:06
In particular, I want to make a request to the Numbers API that we have made use of on Point-Free many times in the past: try await URLSession.shared .data( from: URL( string: "http://numbersapi.com/\(state.number)/trivia" )! ) .0
— 19:31
OK, so that actually loads the data from the network. We then to convert it to a string, which will be the actual fact string sent back to us from the Numbers API: String( decoding: try await URLSession.shared .data( from: URL( string: "http://numbersapi.com/\(state.number)/trivia" )! ) .0, as: UTF8.self )
— 19:45
Then we need to bundle this up into a TaskResult since that’s what the factResponse action expects: TaskResult { String( decoding: try await URLSession.shared .data( from: URL( string: "http://numbersapi.com/\(state.number)/trivia" )! ) .0, as: UTF8.self ) }
— 19:58
And then finally bundle this up into the factResponse case so that it can be sent back into the system when the effect finishes: await .factResponse( TaskResult { String( decoding: try await URLSession.shared .data( from: URL( string: "http://numbersapi.com/\(state.number)/trivia" )! ) .0, as: UTF8.self ) } )
— 20:05
But because state is inout and the effect’s closure is @Sendable , we cannot access state.number directly. Instead we will need to capture it in the effect: return .task { [number = state.number] in … URL(string: "http://numbersapi.com/\(number)/trivia")! … }
— 20:23
And that’s all it takes to get a very simple effect executing. There’s no need to jump through hoops to model dependencies until you are ready for that, and we will delay that work until we are ready to write some tests.
— 20:32
Next we need to handle the factResponse action, which actually consists of two cases since TaskResult s have a success and failure case: case let .factResponse(.success(fact)): case .factResponse(.failure):
— 20:50
In the success case we have a fact loaded, and so we want to present it in an alert: case let .factResponse(.success(fact)): state.alert = AlertState { TextState(fact) } return .none
— 21:10
And in the failure case we will also show an alert and just say that we could not load a number fact: case .factResponse(.failure): state.alert = AlertState { TextState("Could not load a number fact :(") } return .none
— 21:22
That’s all it takes to get the basics of the NumberFactFeature logic and behavior into place. Let’s turn to the view next.
— 21:29
It will be incredibly simple because all it needs to display is a static text view for the current number, a button to fetch a fact, and then some integration code to show an alert when the alert state is populated. We’ll even just paste in the code because there really isn’t much to it: struct NumberFactView: View { let store: StoreOf<NumberFactFeature> var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in VStack { Text("Number: \(viewStore.number)") Button("Get fact") { viewStore.send(.factButtonTapped) } } .alert( store: self.store.scope( state: \.alert, action: NumberFactFeature.Action.alert ) ) } } }
— 21:59
And with that done we can already preview this feature in isolation. Let’s add the NumberFactView to the preview we have at the bottom of the file: struct Previews: PreviewProvider { static var previews: some View { … NumberFactView( store: Store( initialState: NumberFactFeature.State(number: 42), reducer: NumberFactFeature() ) ) .previewDisplayName("Fact") } }
— 22:13
Unfortunately, when we tap the “Get fact” button we immediately get a failure alert. This is happening because technically the Numbers API we are using only supports HTTP and not HTTPS. Now, that is definitely not a great thing, and so we would never want to use this API in real, production code, but for our purposes it is just fine. In order to tell iOS explicitly that we want to allow insecure network requests we have to add something to the app’s Info.plist: <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict> </plist>
— 22:52
With that done we can now finally test out the app in the preview by tapping the button and seeing that a fact appears in an alert.
— 22:58
It is worth noting that this annoyance we are seeing with insecure domains could have been avoided for a little bit longer had we controlled our dependency on the number fact request. Because then in the preview we would use a mock dependency that just immediately returns any fact we want, and we could test that our logic and behavior is correct without even touching the network.
— 23:15
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. Multiple destinations Brandon
— 23:47
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 ?
— 23:56
Let’s try that out…next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0232-composable-navigation-pt11 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 .