EP 246 · Tour of the Composable Architecture · Aug 21, 2023 ·Members

Video #246: Tour of the Composable Architecture: Stacks

smart_display

Loading stream…

Video #246: Tour of the Composable Architecture: Stacks

Episode: Video #246 Date: Aug 21, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep246-tour-of-the-composable-architecture-1-0-stacks

Episode thumbnail

Description

We show how to add stack-based navigation to a Composable Architecture application, how to support many different kinds of screens, how to deep link into a navigation stack, and how to write deep tests for how navigation is integrated into the application.

Video

Cloudflare Stream video ID: c4cef6ddeb11c3e925749ca6c73c5f90 Local file: video_246_tour-of-the-composable-architecture-1-0-stacks.mp4 *(download with --video 246)*

References

Transcript

0:05

Maybe later we can come along and add more tests to this as we add more logic and behavior, or we can make an exhaustive version of this test to make sure we understand how everything in the feature evolves.

0:15

We now have yet another feature under our belts, that of seeing the details of a standup and even editing the standup. Brandon

0:22

There’s still a lot more left to implement in the detail screen, but I think the most interesting thing to look at next is how can we navigate to this new detail screen from the standups list. There are actually two ways to go about this, and we’ve explored both ways in past episodes on Point-Free.

0:36

The first style is what we like to call “tree-based navigation”. This is where you model navigation state with optionals, where nil represents you are not navigated to a feature, a non- nil represents an active navigation. We call this tree-based navigation because the optional states nest forming a tree-like structure. This is the style we have been using so far for our sheets, and it is the style we used when we built the Standups app in vanilla SwiftUI for our “ Modern SwiftUI ” series of episodes.

1:04

But then, for drill-down navigation in particular, there’s a second style that we like to call “stack-based navigation”. This is where you model navigation stack as a flat array of states. The act of drilling down to a feature corresponds to appending a value to the stack, and popping a feature corresponds to removing a value from the stack. We re-factored the vanilla SwiftUI Standups app to use stack-based navigation during a live stream a few months ago.

1:30

Each style has their pros and cons in turns of power, expressiveness, decoupling, safety, ergonomics, and more. There is no single right answer of which to use.

1:40

But, for this application, since we have already shown off tree-based navigation, and we will even have more examples of tree-based navigation coming up soon, we will employ stack-based navigation in order to navigate from the standups list down to the detail screen.

1:53

Let’s get started. Navigating to the detail

1:56

The primary tool for stack-based navigation in SwiftUI is the NavigationStack initializer that takes a binding to some collection: NavigationStack(path: <#Binding<_>#>, root: <#() -> _#>) { … }

2:17

When values are added to the collection in the binding, SwiftUI does the work of figuring out which view to push onto the screen. And when values are removed, SwiftUI figures out how to animate that feature off the screen.

2:29

The first question we need to answer is where should we install this navigation stack. It should be at the root of the application, but technically that means we could wrap the body of our StandupsList view in it: struct StandupsList: View { let store: StoreOf<StandupsListFeature> var body: some View { NavigationStack(path: <#Binding<_>#>) { … } } }

2:45

However, that is not the most ideal place to put this.

2:48

One of the benefits of navigation stacks is that you get to compile all the features that can be presented in the stack in full isolation, with no inter-dependencies between them. For example, we should be able to theoretically extract the entire standups list feature and standup detail feature into their own modules and build each of them in isolation without building the other. And later, once we get to the record meeting feature, we should be able to put that feature in its own isolated module too.

3:20

However, if we stick this NavigationStack inside the standups list view we will unnecessarily couple all of the features to the standups list feature. Sure the detail and record meeting feature can still be isolated, but we will need to compile those two features anytime we want to work on the standups list feature.

3:41

So, we need to back up one more layer to install the NavigationStack , which means we need to introduce another Composable Architecture domain and view. Let’s add a new file.

3:59

We’ll paste in some quick scaffolding of a view with a NavigationStack : import ComposableArchitecture import SwiftUI struct AppView: View { var body: some View { NavigationStack { } } } #Preview { AppView() }

4:05

And we’ll paste in the scaffolding of a new Composable Architecture feature: struct AppFeature: Reducer { struct State { } enum Action { } var body: some ReducerOf<Self> { Reduce { state, action in switch action { } } } }

4:14

This feature is going to encapsulate all of the features that can be presented inside the stack. This includes the standups list at the root of the navigation stack, which is the view that cannot be popped off ever, as well as the detail feature. And in the near future there will also be the record meeting feature.

4:36

The feature at the root of the navigation stack is a little different from all the features pushed onto the stack because, well, it can never be popped off. So, we will integrate its domain into the AppFeature domain by just holding onto its state and actions: struct AppFeature: Reducer { struct State { var standupsList = StandupsListFeature.State() } enum Action { case standupsList(StandupsListFeature.Action) } var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .standupsList: return .none } } } }

5:23

That integrates the domain of the standups list into the app feature, but it doesn’t integrate any of the logic or behavior from the standups list into the app feature. To do that we need to somehow compose the StandupsListFeature reducer into the body of the AppFeature reducer.

5:39

This can be done by using a special reducer called Scope that allows you to carve out a little bit of domain from the parent in order to run a child reducer. This is quite similar, in principle, to the scoping operation we have seen a few times defined on stores, but its syntax is a little different: Scope( state: <#WritableKeyPath<ParentState, ChildState>#>, action: <#CasePath<ParentAction, ChildAction>#>, child: <#() -> Reducer#> )

6:05

You specify a key path to isolate the piece of state you want to operate on from the parent state, as well as a case path to isolate a particular case of the parent actions, and then a trailing closure to describe the reducer you want to run on that child domain: var body: some ReducerOf<Self> { Scope( state: \.standupsList, action: /Action.standupsList ) { StandupsListFeature() } Reduce { state, action in switch action { case .standupsList: return .none } } }

6:40

That is all it takes to integrate the standups list feature reducer into the app feature.

6:55

Then we can display the standups list at the root of the navigation stack by adding a store and scoping it down to the standups list domain: struct AppView: View { let store: StoreOf<AppFeature> var body: some View { NavigationStack { StandupsListView( store: self.store.scope( state: \.standupsList, action: { .standupsList($0) } ) ) } } } #Preview { AppView( store: Store(initialState: AppFeature.State()) { AppFeature() } ) }

7:50

And already we can see the preview does show off the standups list, and there’s even an “Add” button in the top-right. And we can even add a new standup.

8:07

Now we just have to figure out how to push the detail screen onto the stack. Well, luckily for us the Composable Architecture gives us all the tools we need to accomplish this.

8:18

It starts by adding a field to the app feature’s state that represents the current stack of features being presented. The library comes with a type specifically tuned for this called StackState : struct State { var path = StackState<<#???#>>() … }

8:43

It is generic over the state of the features that can be presented in the stack. Right now that’s just the detail screen, so we could do something like this: var path = StackState<StandupDetailFeature.State>()

9:04

However, that would not be very future forward thinking. It is very rare that a navigation stack presents a homogeneous set of screens. Typically a stack can present many types of screens, and that is even the case in this app. Soon we will want to be able to drill down to the record meeting feature from a detail, and even drill down to the list of past meetings.

9:26

So, if we overly specify the path to only work on StandupDetailFeature.State we will preclude ourselves from incorporating those other types of destinations.

9:35

And it is for this reason that we will do a little bit of upfront work to set ourselves up for the future. We are going to model an enum of all the different places we can navigate to inside the stack. Further, each destination in the stack is going to be a fully fledged Composable Architecture feature of its own, so really we should find a way to package up all the destinations into a single feature.

9:59

The more straightforward pattern to accomplish this is to simply define a new, inner reducer called path: struct AppFeature: Reducer { … struct Path: Reducer { } … }

10:06

…and it will encapsulate all of the features we can navigate to in the stack.

10:11

This means the state will be an enum, which isn’t super common, but is appropriate here: struct Path: Reducer { enum State { case detail(StandupDetailFeature.State) } }

10:33

The Action enum can hold the detail actions like normal: struct Path: Reducer { enum State { case detail(StandupDetailFeature.State) } enum Action { case detail(StandupDetailFeature.Action) } }

10:46

And then finally we integrate StandupDetailFeature reducer into the Path reducer by using the Scope tool that we saw a moment ago. It allows you to carve out a bit of child domain from the parent in order to operate on it with a child reducer. It even works on states that are enums instead of structs: Scope( state: <#CasePath<ParentState, ChildState>#>, action: <#CasePath<ParentAction, ChildAction>#>, child: <#() -> Reducer#> )

11:18

You specify a case path to isolate the piece of state you want to operate on from the State enum, and the same for the actions, and then a trailing closure to describe the reducer you want to run on that child domain: struct Path: Reducer { enum State { case detail(StandupDetailFeature.State) } enum Action { case detail(StandupDetailFeature.Action) } var body: some ReducerOf<Self> { Scope(state: /State.detail, action: /Action.detail) { StandupDetailFeature() } } }

11:37

So, it is a little bit of upfront work, but you only have to do it once for each new kind of feature used in the stack, which isn’t very often. For example, once we get around to implementing the record feature, we will only need to do the following: struct Path: Reducer { enum State { case detail(StandupDetailFeature.State) case record(RecordMeeting.State) } enum Action { case detail(StandupDetailFeature.Action) case record(RecordMeeting.Action) } var body: some ReducerOf<Self> { Scope(state: /State.detail, action: /Action.detail) { StandupDetailFeature() } Scope(state: /State.record, action: /Action.record) { RecordMeeting() } } }

12:28

And the benefits of doing this little bit of upfront work is that it completely integrates all the features together into a single package, giving you infinite flexibility in how you can layer on additional logic or communicate from child to parent.

12:45

So, we’ve now modeled a Path reducer for all of our destinations, and we can use it in our state: struct State { var path = StackState<Path.State>() … }

12:58

And if something is going in the state, then there is probably also something going in the actions, just as we saw with presentation state and presentation actions.

13:06

This time we are going to use StackAction , and it is generic over the path’s state and actions: enum Action { case path(StackAction<Path.State, Path.Action>) … }

13:14

Just as the PresentationAction abstracted away the 2 most fundamental things one can do with a presentation, which is to send a child action or dismiss, StackAction does the same, but for navigation stacks.

13:44

In particular, there are 3 fundamental things you can do in a stack: a child in the stack can send an action, you can pop a feature off the stack, or you can push a feature: public enum StackAction<State, Action> { indirect case element(id: StackElementID, action: Action) case popFrom(id: StackElementID) case push(id: StackElementID, state: State) }

14:36

By adding these actions to our App domain we get instant access to see whenever a child feature sends an action, or whenever a feature is popped from or pushed to the stack. We are going to take advantage of this very soon, but we don’t have anything to do right now so we can just return no effects from this new case: case .path: return .none

15:15

The final step, as far as the reducer is concerned, is to integrate the Path reducer into the App reducer, and this is quite similar to what we’ve done previously with the ifLet reducer operator. Except this time, since we are dealing with a collection instead of an optional, we naturally are going to use the forEach operator: .forEach( <#WritableKeyPath<State, StackState<_>>#>, action: <#CasePath<Action, StackAction<_, _>>#>, destination: <#() -> Reducer#> )

15:49

It is very similar to ifLet , except you provide a key path that isolates the stack state and stack actions, as well as a trailing closure to run the Path reducer: var body: some ReducerOf<Self> { Reduce { state, action in … } .forEach(\.path, action: /Action.path) { Path() } }

16:12

Note that we don’t have to use \.$path here because StackState is not a property wrapper, and thus has no projected value, whereas PresentationState was a property wrapper.

16:28

That’s all there is for the reducer layer. Next in the view layer we will construct the navigation stack, but we have to use a Composable Architecture specific tool. It’s called NavigationStackStore , and it takes 3 arguments: NavigationStackStore( <#Store<StackState<State>, StackAction<State, Action>>#>, root: <#() -> View#>, destination: <#(State) -> View#> ) { … }

17:06

The first is a store that is focused on the stack state and action domain, which can be provided with another store scope: NavigationStackStore( self.store.scope(state: \.path, action: { .path($0) }), root: <#() -> View#>, destination: <#(State) -> View#> ) { … }

17:21

The second is a trailing closure that describes the root view of the navigation stack, which is precisely the standups list that we already have in place: NavigationStackStore( self.store.scope(state: \.path, action: { .path($0) }) ) { StandupsList( store: self.store.scope( state: \.standupsList, action: { .standupsList($0) } ) ) } destination: { }

17:46

And the last is another trailing closure that is handled a piece of the enum path state when a new feature is pushed to the stack state. This is our opportunity to switch over the state and decide which view to present in each case: } destination: { state in switch state { case .detail: } }

18:06

We might hope we can just construct a detail view right in line: } destination: { state in switch state { case .detail: StandupDetailView(store: <#StoreOf<StandupDetail>#>) } } …but in order to do that we need a store to provide.

18:20

But this time we can’t simply scope the store down to the detail domain. We need to first know which element in the stack state is being presented, and then would further need to extract out the detail state from the path state enum.

18:44

But, there’s another tool we can use that will handle all of that for us behind the scenes. It’s called CaseLet , and you simply describe how to transform the Path.State and Path.Action enums into a particular case, and it will derive a store focused only on that data so that it can be passed on to the StandupDetailView : CaseLet( /AppFeature.Path.State.detail, action: AppFeature.Path.Action.detail, then: StandupDetailView.init(store:) )

20:21

It’s a mouthful, but it’s worth pointing out how this will look in a future version of the Composable Architecture that can take advantage of all the new fancy tools coming to iOS 17: switch $0.state { case .detail: if let store = $0.scope( state: \.detail, action: { .detail($0) } ) { StandupDetailView(store: store) } } We won’t need a special CaseLet view and we will get the benefit of code completion to help us out. But we can’t do that yet, so let’s undo.

21:28

Everything now compiles, and that completes the integration of the all the feature views together.

21:54

Now we just need a way to actually append data to the stack state so that we can drill down to the detail screen. The easiest way to do this is to use a special NavigationLink initializer that allows you to specify path state to be append to the stack state when the link is tapped: ForEach(viewStore.state) { standup in NavigationLink( state: AppFeature.Path.State.detail( StandupDetailFeature.State(standup: standup) ) ) { CardView(standup: standup) } .listRowBackground(standup.theme.mainColor) }

23:16

It is worth noting that while using NavigationLink is incredibly convenient, it does slightly hinder modularity. In order for us to be able to compile the standups list feature we must now also compile the standup detail feature. Previously that wasn’t the case. We could have put the standups list in its own module with no dependencies on any of the other features.

23:49

However, this is also true of vanilla SwiftUI. Typically the use of NavigationLink necessarily couples the feature housing the link with the feature being linked to. The only time this is not true is when using the magical, type-erased NavigationPath type, but then there are a lot of tradeoffs to contend with when doing that, such as having each of the stack’s features operate as an isolated island, and thus not having a good way to communicate between features or write tests for features.

24:24

However, things are a little better in the Composable Architecture than in your typical vanilla SwiftUI application. For one thing, the NavigationLink only needs access to the state of the feature it is navigating to. Not the actions, reducer, dependencies, view or anything else the feature needs to do its job. So technically just the state structs of all the features in the stack could be extracted to its own module, which should compile very quickly, and then we could link to features without coupling everything together.

25:06

And if you want full decoupling, then you can always just use a plain button that sends an action, and then have the parent feature, in this case the app feature, intercept that action and push something onto the stack. That style also allows you to layer on additional logic when tapping the button, such as performing validation logic or executing side effects, neither of which is possible with NavigationLink .

25:51

That is all it takes. We can preview it, but we can’t see this behavior in the standups list preview. We have to back up to the app preview to see it work. That’s because that is where the integration logic is.

26:04

We can also make the app preview a bit easier to test this behavior by making sure there is already a mock standup in the list: #Preview { AppView( store: Store( initialState: AppFeature.State( standupsList: StandupsListFeature.State( standups: [.mock] ) ) ) { AppFeature() } ) }

26:19

Now we can tap the standup row to drill down to that standup.

26:28

And we can even print the changes in the reducer to see what exactly is happening when we tap the link: AppFeature() ._printChanges()

26:32

Running again and tapping “Design” shows the following in the logs: received action: AppFeature.Action.path( .push( id: #1, state: .detail( StandupDetailFeature.State( _editStandup: nil, standup: Standup( id: UUID(A1243E7E-5FF1-48F3-B10D-4024BAF81350), attendees: [ [0]: Attendee( id: UUID(BA90B8F1-E012-4DAC-A564-27EA73B3F7FC), name: "Blob" ), [1]: Attendee( id: UUID(0EE25A90-CE9D-4D92-95E7-CBE8993B1B9A), name: "Blob Jr" ), [2]: Attendee( id: UUID(A555FEDC-FB65-46D5-B89F-379327DF30EF), name: "Blob Sr" ), [3]: Attendee( id: UUID(3F504847-AEA4-44AC-B85B-155030465F20), name: "Blob Esq" ), [4]: Attendee( id: UUID(6DF7D839-F915-4067-9C98-DEF73AF91A7F), name: "Blob III" ), [5]: Attendee( id: UUID(16728C62-F1B4-467C-89F8-2100DE1A5099), name: "Blob I" ) ], duration: 1 minute, meetings: [ [0]: Meeting( id: UUID(FF61C1FB-800E-4204-8420-9BCD18ED11BB), date: Date(2023-07-02T13:54:38.195Z), transcript: """ Lorem ipsum dolor sit amet, consectetur \ adipiscing elit, sed do eiusmod tempor \ incididunt ut labore et dolore magna \ aliqua. Ut enim ad minim veniam, quis \ nostrud exercitation ullamco laboris \ nisi ut aliquip ex ea commodo \ consequat. Duis aute irure dolor in \ reprehenderit in voluptate velit esse \ cillum dolore eu fugiat nulla pariatur. \ Excepteur sint occaecat cupidatat non \ proident, sunt in culpa qui officia \ deserunt mollit anim id est laborum. """ ) ], theme: .orange, title: "Design" ) ) ) ) ) AppFeature.State( path: [ + #1: .detail( + StandupDetailFeature.State( + _editStandup: nil, + standup: Standup( + id: UUID(A1243E7E-5FF1-48F3-B10D-4024BAF81350), + attendees: [ + [0]: Attendee( + id: UUID(BA90B8F1-E012-4DAC-A564-27EA73B3F7FC), + name: "Blob" + ), + [1]: Attendee( + id: UUID(0EE25A90-CE9D-4D92-95E7-CBE8993B1B9A), + name: "Blob Jr" + ), + [2]: Attendee( + id: UUID(A555FEDC-FB65-46D5-B89F-379327DF30EF), + name: "Blob Sr" + ), + [3]: Attendee( + id: UUID(3F504847-AEA4-44AC-B85B-155030465F20), + name: "Blob Esq" + ), + [4]: Attendee( + id: UUID(6DF7D839-F915-4067-9C98-DEF73AF91A7F), + name: "Blob III" + ), + [5]: Attendee( + id: UUID(16728C62-F1B4-467C-89F8-2100DE1A5099), + name: "Blob I" + ) + ], + duration: 1 minute, + meetings: [ + [0]: Meeting( + id: UUID(FF61C1FB-800E-4204-8420-9BCD18ED11BB), + date: Date(2023-07-02T13:54:38.195Z), + Lorem ipsum dolor sit amet, consectetur \ + adipiscing elit, sed do eiusmod tempor \ + incididunt ut labore et dolore magna \ + aliqua. Ut enim ad minim veniam, quis \ + nostrud exercitation ullamco laboris \ + nisi ut aliquip ex ea commodo \ + consequat. Duis aute irure dolor in \ + reprehenderit in voluptate velit esse \ + cillum dolore eu fugiat nulla pariatur. \ + Excepteur sint occaecat cupidatat non \ + proident, sunt in culpa qui officia \ + deserunt mollit anim id est laborum. + ) + ], + theme: .orange, + title: "Design" + ) + ) + ) ], standupsList: StandupsListFeature.State(…) )

26:37

This clearly shows that when the link is tapped a .path(.push) action is sent, and an element is appended to the end of the stack.

27:12

Let’s also go ahead and install the AppView in the entry point of the application, and because we don’t yet have data persistence let’s add some mock data to the feature state like we did in the preview: import ComposableArchitecture import SwiftUI @main struct StandupsApp: App { var body: some Scene { WindowGroup { AppView( store: Store( initialState: AppFeature.State( standupsList: StandupsListFeature.State( standups: [.mock] ) ) ) { AppFeature() } ) } } }

27:25

And now we can run it in the simulator to see that it all works there too.

27:33

We can have a little fun with this now by playing around with the deep linking capabilities of these tools. What if we wanted to deep link into a standup where the edit screen is already up, the title has been changed, and the 4th attendee is focused? It’s as simple as describing this in state: var editedStandup = Standup.mock let _ = editedStandup.title += " Morning Sync" AppView( store: Store( initialState: AppFeature.State( path: StackState([ .detail( StandupDetailFeature.State( editStandup: StandupFormFeature.State( focus: .attendee(editedStandup.attendees[3].id) standup: editedStandup ), standup: .mock ) ) ]), standupsList: StandupsListFeature.State( standups: [.mock] ) ) ) { AppFeature() } )

29:25

If we run the app, we are immediately drilled-down with a sheet presenting the edited standup, focused on the attendee’s name. And if we save, these changes are persisted to the detail.

29:55

And this kind of deep linking is just not possible in Apple’s Scrumdinger. Its navigation links aren’t driven by state whatsoever. The only way to drill down to the detail view is for the user to literally tap the link. You can’t simply construct some state, hand it to SwiftUI, and let SwiftUI do the rest.

30:37

There is one problem though. While we do have navigation from list to detail, and even navigation from detail to edit, there is still a piece of integration missing between all 3 of these features. Let’s see how this manifests as a real bug right.

30:43

If we return to the simulator, we have the detail of our edited standup in front of us, but if we go back to the main standup list, we will see the edits did not make it all the way back to the root.

31:02

We need some way for the detail screen to communicate back to the parent when the edit happened so that it can update its state. There are a few ways we can do this.

31:14

By far the easier is for the app feature to simply listen for when the detail screen is popped off the stack, and then update the standups list with the freshest data from the detail domain. This is incredibly easy because we can listen for every little action coming through the system at the AppFeature level. So we can pattern match on the popFrom action: case let .path(.popFrom(id)):

31:25

That is the action that is sent when the user taps back in the top-left or swipes to pop the screen off the stack. We are handed the ID of the feature popped off, which can be used to look up the state for that feature in the stack state: state.path[id: id]

31:45

If that feature is the standup detail, then we can take the freshest standup state in the detail, and replace the data in the list with it: case let .path(.popFrom(id)): guard case let .some(.detail(detailState)) = state.path[id: id] else { return .none } state.standupsList .standups[id: detailState.standup.id] = detailState.standup return .none

32:25

And that is truly all it takes. Now when we go through that flow again, we will see that when we pop back to the root we have the freshest version of the standup. That was incredibly easy!

32:44

You may have noticed, though, that the state did not update in the list until the pop animation finished. That is because SwiftUI does not write to the binding in the navigation stack until the animation finishes, and so we don’t process the action and update the state until them.

33:12

Another alternative is for the parent snoop on the saveStandupButtonTapped action in the detail feature, which can be done by pattern matching on the element action: case let .path( .element( id: id, action: .detail(.saveStandupButtonTapped) ) ):

33:29

And inside this action we can perform the exact same logic as before: case let .path( .element( id: id, action: .detail(.saveStandupButtonTapped) ) ): guard case let .some(.detail(detailState)) = state.path[id: id] else { return .none } state.standupsList .standups[id: detailState.standup.id] = detailState.standup return .none

33:52

However, this is not the most ideal way to facilitate child-to-parent communicate. The problem is that the parent feature needs intimate knowledge of how the child operates in order to layer on this logic. It specifically needs to know that the child domain uses a saveStandupButtonTapped action to perform its standup updating logic. But someday in the future that may not be the only way a standup could be updated.

34:20

It would be far better if the child could form a lightweight interface for telling the parent feature when it had updated the standup, and then the parent could do whatever it wants with that info.

34:31

The way one does that is by adding what we like to call “delegate” actions to the child domain: enum Action { case delegate(Delegate) … enum Delegate { } }

34:48

These are actions that the child domain sends purely to communicate to the parent. The parent is free to intercept any of these actions and do whatever it wants.

34:53

For example, we could have a delegate action that the child sends when the standup is updated: enum Delegate { case standupUpdated(Standup) }

34:57

It can even pass along the freshest standup data.

35:00

Then in the child reducer we can just return .none for that action: case .delegate: return .none And in general it is never appropriate for the child feature to perform logic in its own delegate actions.

35:12

But, what the child can do, is send that action whenever it wants to communicate to the parent via an effect: case .saveStandupButtonTapped: … return .send(.delegate(.standupUpdated(standup)))

35:34

But technically we should also be doing the same when a past meeting is deleted too: case let .deleteMeetings(atOffsets: indices): … return .send(.delegate(.standupUpdated(state.standup)))

35:45

Huh, this is getting complicated. Now I’m worried that there may be other places we are updating the standup but not communicating to the parent about that update, and hence it will not be updated in the root collection of standups.

35:56

Well, the Composable Architecture has a wonderful tool to help with this. There is an operator you can chain onto any reducer to be notified when it processes an action that causes a piece of state to change: .onChange( of: <#(_) -> Equatable#>, <#(Equatable, Equatable) -> Reducer#> )

36:20

You provide a closure to extract a piece of equatable state from the reducer’s state, and then you get to provide a reducer to run when that state changes.

36:28

So, we can listen for when the standup piece of state changes, and when it does notify the parent domain: .onChange(of: \.standup) { oldValue, newStandup in Reduce { state, action in .send(.delegate(.standupUpdated(newStandup))) } }

36:55

That’s all it takes. And if we ever introduce more UI to this feature that allows editing the standup, we will automatically be covered. No additional work necessary.

37:18

With that done we can now intercept this delegate action in the parent: case let .path( .element(id: _, action: .detail(.delegate(action))) ):

37:27

And we can switch on the delegate action so that we make sure to handle them all: switch action { }

37:31

And we can handle the standupUpdated action by just turning around and updating the standup list state: case let .standupUpdated(standup): state.standupsList.standups[id: standup.id] = standup return .none

38:03

The application works exactly as it did before, but now with the child domain telling the parent exactly when something happens, and letting the parent figure out how to react. Testing the edit flow

38:14

OK, things are looking absolutely incredible. Not only have we implemented stack-based navigation for drilling down to the detail screen from the standups list screen, but thanks to the way we integrate features together we get easy communication between child and parent. It was a cinch for the parent to figure out when the child had edited its standup so that it could grab the freshest data. Doing this in vanilla SwiftUI can be very difficult and precarious. Stephen

38:39

Before moving onto the next bit of functionality in the app, let’s see how easy it is to write a test that proves everything works the way we expect. Sure we confirmed in the simulator that everything seems to work, but by getting an automated test into place we can make changes in the future to this code without fear that we will accidentally break something.

38:57

We are going to start by writing a fully exhaustive test for the full flow of the user drilling down to a standup, making edits, saving, and confirming that those changes were made to the root. This test is going to be quite verbose, but it does capture absolutely everything in the system all at once.

39:16

Let’s get some basic scaffolding into place for a new test: import ComposableArchitecture import XCTest @testable import Standups @MainActor final class AppTests: XCTestCase { func testEdit() async { } }

39:26

We are going to need to refer to a particular standup multiple times in this test, so let’s just create one right at the top. We can even use the mock we’ve defined before: func testEdit() async { let standup = Standup.mock }

39:33

The mock standup is just one for a “Design” standup and has 6 attendees.

39:39

Next let’s create a test store that has this standup already sitting in the root list: let store = TestStore( initialState: AppFeature.State( standupsList: StandupsListFeature.State( standups: [standup] ) ) ) { AppFeature() }

40:12

But to construct a test store we need the reducer we are testing to have equatable state: struct AppFeature: Reducer { struct State: Equatable { … } … }

40:21

Which means the Path reducer’s state also needs to be equatable: struct Path: Reducer { enum State: Equatable { … } … }

40:28

We will emulate the user tapping on the navigation link for this standup, which behind the scenes just sends a push stack action: await store.send( .path( .push(id: <#StackElementID#>, state: <#AppFeature.Path.State#>) ) )

40:42

Here we already see something interesting.

40:44

In order to send a push action you have to specify the ID of the state being pushed onto the stack. We have actually come across this ID once before. Whenever we are destructuring the stack action in the app reducer, there is an ID we can extract: case let .path( .element(id: _, action: .detail(.delegate(delegate))) ):

40:57

This ID is what allows us to look elements up in the stack state: state.path[id: id] // Path.State?

41:05

This ID is purposely opaque, and the library generates them for us. We never have to generate them ourselves in application code. Instead, we can extract them from stack actions and use them to look up elements in stack state.

41:19

However, in tests we do explicitly refer to these IDs, and it allows us to prove we know exactly what elements are in the stack at anytime. IDs can be constructed with a simple integer: await store.send( .path(.push(id: 0, state: <#AppFeature.Path.State#>)) )

41:34

…but it must be stressed that these IDs do not refer to a position index in the stack state at all. They are instead generational IDs that keep auto-incrementing. So, if we push a screen on with ID 0, then pop it, and then push another screen on, it will have ID 1, not 0. The IDs keep going up.

41:53

And since this is the first feature we are pushing onto the stack, it will have ID 0. And then we can decide what state we want to push onto the stack. In this case, it’s the detail feature: await store.send( .path( .push( id: 0, state: .detail( StandupDetailFeature.State(standup: standup) ) ) ) )

42:07

It’s worth mentioning that if we run the app in the simulator and inspect the logs, we will see the exact same kind of action sent into the system when we tap the navigation link: received action: AppFeature.Action.path( .push( id: #1, state: .detail( StandupDetail.State( …

42:24

So there are no real surprises here. The test store is using actions in the exact same way the store uses actions when run in the simulator or on device.

42:33

Next we have to assert on what state changes. We expect an element to be added to the stack with ID 0, which we can do with the special ID subscript: await store.send( .path( .push( id: 0, state: .detail( StandupDetail.State(standup: standup) ) ) ) ) { $0.path[id: 0] = .detail( StandupDetailFeature.State(standup: standup) ) }

42:52

Again we want to emphasize that the ID is not positional at all, and we hope the id argument in the subscript makes that clear. We are referring to an element by its ID, not by its position in the stack.

43:03

OK, if we run tests they pass. But so far this is exercising only the most basic behavior in our application. Let’s keep going.

43:13

Next we can emulate the user tapping the “Edit” button inside the standup detail. We can use autocomplete to help us figure out how exactly to do that. We know we want to send an element action in the path, so we can start there: await store.send( .path( .element( id: <#StackElementID#>, action: <#AppFeature.Path.Action#> ) ) )

43:29

The ID argument is the ID in the stack that you want to reference, and as we see just above, that ID is 0: await store.send( .path(.element(id: 0, action: <#AppFeature.Path.Action#>)) )

43:36

Then we can use auto-complete for the path action to figure out our choices. In particular, we want to send an action in the detail domain, so we can do that: await store.send( .path( .element(id: 0, action: .detail(<#StandupDetail.Action#>)) ) )

43:41

And finally we can use auto-complete again to figure out what action we want to send in the detail domain, which is the editButtonTapped : await store.send( .path( .element(id: 0, action: .detail(.editButtonTapped)) ) )

43:45

It’s incredible just how powerful it is to navigate the deeply nested enums to describe precisely what action you want to send in any domain whatsoever.

43:53

Next we need to assert on how state changes. We expect the editStandup state in the detail state to be populated, but things are a bit complicated now. Sure we can get access to the element in the path at ID 0 like this: $0.path[id: 0]

44:07

But that element is an entire enum with a case for each kind of feature we can navigate to. We need to somehow extract out the detail state from it, mutate that state, and then stick it back into the Path.State enum.

44:18

Well, since we know that it will be very common to put enum states into stack states we have built a tool for this directly into StackState . There is a subscript on StackState that allows you to specify the ID you want to access, and the case of the enum you want to mutate: $0.path[id: 0, case: <#CasePath<AppFeature.Path.State, Case>#>]

44:35

Just specify a case path that isolates which case of the state enum you want to modify, you can perform any mutations you want. In this case, it’s the detail case: $0.path[id: 0, case: /AppFeature.Path.State.detail]

44:46

And the way we expect this to mutate is that the editStandup state is populated with some state: $0.path[id: 0, case: /AppFeature.Path.State.detail]? .editStandup = StandupFormFeature.State( standup: standup )

45:01

That’s all it takes, and this test also passes.

45:05

Also worth mentioning, as we have in the past, that in a future version of the Composable Architecture we will be able to leverage macros to shorten the creation of case paths a bit: $0.path[id: 0, case: #casePath(\.detail)]? …

45:22

That will be nice.

45:25

Looking good so far. The next thing we want to emulate is making a change inside the edit sheet because ultimately we want to see that that change is made to the root list of standups too. This time we are going to have even more layers to go through to send the action. We will need to go through the path, then the detail, then the edit feature, and then even the binding: var editedStandup = standup editedStandup.title = "Point-Free Morning Sync" await store.send( .path( .element( id: 0, action: .detail( .editStandup( .presented(.set(\.$standup, editedStandup)) ) ) ) ) )

46:22

It’s intense, but it is also expressing sending an action through the integration of 3 entire features, so it’s also pretty incredible.

46:29

We want to assert that the standup inside the form feature was mutated, and we can do that with the special StackState subscript again: await store.send( .path( .element( id: 0, action: .detail( .editStandup( .presented(.set(\.$standup.title, "Point-Free")) ) ) ) ) ) { $0.path[id: 0, case: /AppFeature.Path.State.detail]? .editStandup?.standup.title = "Point-Free Morning Sync" }

46:56

Next we will emulate the user tapping on the “Save” button, which should dismiss the edit sheet, as well as update the local standup in the detail: await store.send( .path( .element( id: 0, action: .detail(.saveStandupButtonTapped) ) ) ) { $0.path[id: 0, case: /AppFeature.Path.State.detail]? .editStandup = nil $0.path[id: 0, case: /AppFeature.Path.State.detail]? .standup.title = "Point-Free Morning Sync" }

47:33

OK, so that seems reasonable, but remember that when the “Save” button is tapped, the detail feature sends a delegate action to communicate to the parent, and that’s when the parent features gets the opportunity to update the root list of standups.

47:45

How can we assert on that?

47:47

Well, let’s run tests right now just to see what happens:

47:50

Looks like we get a test failure: The store received 1 unexpected action after this one: … Unhandled actions: • .path(.element(id:, action: .detail(.delegate(.standupUpdated))))

47:53

This is telling us that an action was sent back into the system from an effect, but we didn’t assert on it. This is an amazing test failure to have. It can alert you to when things are happening in your features that you were not aware of. If we didn’t assert on this behavior then it could be hiding multiple kinds of bugs.

48:09

First, it could be a bug that an action was being sent into the system at all, and so maybe there’s something to fix to prevent that. Or, if the action was expected, maybe the way state changes after receiving the action has a bug. And so without asserting on that we would be covering up a bug.

48:23

And so this is really great, and the test failure is directly related to our delegate actions. What we need to do is invoke the receive method on TestStore : await store.receive(<#⎋#>)

48:35

…in order to assert that we expect to receive an action. However, this method has many overloads for various ways of receiving actions, each with their own uses. But the kind we want to use right now is where we very explicitly say which action we expect to receive. But to do that we need the Action enums in our domains to be equatable, so let’s do that real quick.

49:15

OK, now we can assert that we expect to receive the standupUpdated delegate action: await store.receive( .path( .element( id: 0, action: .detail(.delegate(.standupUpdated(<#Standup#>))) ) ) )

49:33

But because the delegate action is sent with the final updated standup, we have to provide that too. So, let’s make a quick scratch standup with the changes from the original that we expect, and pass that along to the delegate action: await store.receive( .path( .element( id: 0, action: .detail( .delegate(.standupUpdated(editedStandup)) ) ) ) ) { }

49:40

And now we can make our final state assertion, which is that when this delegate action is received we expect the title in the first standup to be updated: await store.receive( .path( .element( id: 0, action: .detail( .delegate(.standupUpdated(editedStandup)) ) ) ) ) { $0.standupsList.standups[0].title = "Point-Free Morning Sync" }

50:02

And just like that the test passes, and we now have a truly deep integration test that exercises how 3 completely different features communicate with each other. This is absolutely incredible.

50:13

However, it is also very verbose. Now maybe you do want to maintain a few of these comprehensive, deep, exhaustive tests for the integration of your features. But if you are wanting to test dozens of user flows, you are not going to wait to assert on all these little details every single time.

50:28

So, let’s quickly copy-and-paste this test to experiment with what a minimal non-exhaustive version looks like: func testEdit_NonExhaustive() async { }

50:36

First we’ll make the store non-exhaustive: store.exhaustivity = .off

50:43

I am going to keep all the sends and receives, but just remove the assertion closures on all except for the last one: await store.send( .path( .push( id: 0, state: .detail( StandupDetailFeature.State(standup: standup) ) ) ) ) await store.send( .path( .element(id: 0, action: .detail(.editButtonTapped)) ) ) var editedStandup = standup editedStandup.title = "Point-Free Morning Sync" await store.send( .path( .element( id: 0, action: .detail( .editStandup( .presented(.set(\.$standup, editedStandup)) ) ) ) ) ) await store.send( .path( .element( id: 0, action: .detail(.saveStandupButtonTapped) ) ) ) await store.receive( .path( .element( id: 0, action: .detail( .delegate(.standupUpdated(editedStandup)) ) ) ) ) store.assert { $0.standupsList.standups[0].title = "Point-Free Morning Sync" }

51:03

This still passes, but doesn’t assert on all of the nitty gritty details inside each child feature. It just exercises the very broad behavior that if we navigate to a standup detail, make some edits, and save those edits, then the root list of standups is updated.

51:20

We can even simplify this a little bit. What if we didn’t want to have to assert on the communicate mechanism between the parent and child by receiving that delegate action. We can just flush all the received actions, and then make an assertion at the very end to confirm that the standups collection was changed: await store.send( .path( .push( id: 0, state: .detail( StandupDetailFeature.State(standup: standup) ) ) ) ) await store.send( .path( .element(id: 0, action: .detail(.editButtonTapped)) ) ) await store.send( .path( .element( id: 0, action: .detail( .editStandup( .presented(.set(\.$standup.title, "Point-Free")) ) ) ) ) ) await store.send( .path( .element( id: 0, action: .detail(.saveStandupButtonTapped) ) ) ) await store.skipReceivedActions() store.assert { $0.standupsList.standups[0].title = "Point-Free" } And that’s pretty amazing. Confirming standup deletion

51:48

We now have implemented 3 full features in isolation, and then figured out how to integrate them together so we can navigate to one from the other, and have them communicate with each other. And we now have some pretty substantial logic in the app, and best part is that it is all unit testable. We have written some pretty amazing tests that prove that when the user goes through a sequence of particular steps, the application state changes how we expect. Brandon

52:10

Let’s move onto the next feature. We have a delete button at the bottom of the detail screen. What we would like to happen is when the user taps the button, and alert appears asking the user to confirm deletion, and if they confirm, then we should not only delete the standup from the root list, but we should also pop the detail off the stack.

52:32

This will give us the opportunity to flex our muscles when it comes to navigation and child-to-parent communication, so let’s give it a shot.

52:40

Let’s start with some domain modeling in the detail feature. Presenting an alert is just like presenting any other kind of UI, such as a sheet, popover, cover and more. We will model the alert with some optional state, where a non- nil value represents an alert is showing, and nil represents no alert is showing.

53:00

We can use the Composable Architecture’s tools that we have already been utilizing to handle alerts too. We will hold onto some optional state that is annotated with the @PresentationState property wrapper: struct StandupDetail: Reducer { struct State: Equatable { @PresentationState var alert: …? … } … }

53:14

…but what kind of state should we hold onto?

53:17

The Composable Architecture comes with a special data type that allows you to represent all the various parts of an alert as a simple value type, which means we can actually write tests against when the alert shows and what data it holds.

53:33

The type is called AlertState : @PresentationState var alert: AlertState<…>?

53:37

…and it is generic over the type of actions that it can send. We will model this as an additional enum of actions in the domain, and right now there is only one single action that can take place in the alert, and that is of confirm deletion of the standup: enum Alert { case confirmDeletion }

54:06

And now we use that type: @PresentationState var alert: AlertState<Action.Alert>?

54:12

So, that takes care of representing the alert in state. Let’s do the same for actions. It can be done exactly as we have seen before, using PresentationAction : enum Action: Equatable { case alert(PresentationAction<Alert>) … }

54:27

Next we can handle the new actions in the reducer: case .alert(.presented(.confirmDeletion)): // TODO: Delete this standup return .none case .alert(.dismiss): return .none

55:00

Soon we will be doing work in the confirmDeletion action to communicate to the parent that we want the standup deleted, as well as pop the detail screen off the stack. But we will get to that in a moment.

55:08

While we are in the reducer let’s go ahead and implement the logic that shows the alert when the “Delete” button is tapped. We can do this by populate the alert state with a value: case .deleteButtonTapped: state.alert = AlertState { } actions: { } return .none The AlertState type can be constructed with trailing closures that describe the title, the actions, and the message. We don’t need the message, so we will concentrate on just the title and buttons right now.

55:54

In the title trailing closure you can construct text views, much like you would in vanilla SwiftUI, but we will use a type called TextState instead: state.alert = AlertState { TextState("Are you sure you want to delete?") } actions: { }

56:14

This is necessary because SwiftUI’s native Text view is not equatable. Well, actually it is equatable, it just don’t have a reasonable conformance. We do extra work in TextState to make sure that two similar textual values are actually equal as values.

56:26

Next in the actions trailing closure you can construct buttons for the alert, much like you would in vanilla SwiftUI, but again we need to use a special type in order to embrace value semantics and have a reasonable equatable conformance: state.alert = AlertState { TextState("Are you sure you want to delete?") } actions: { ButtonState( role: <#ButtonStateRole?#>, action: <#ButtonStateAction<Action>#> ) { } }

56:32

It’s called ButtonState , and it allows you to describe the role of the button, the action you want to send when it is tapped, as well as a trailing closure for the label of the button. So, let’s fill that out: state.alert = AlertState { TextState("Are you sure you want to delete?") } actions: { ButtonState( role: .destructive, action: .confirmDeletion ) { TextState("Delete") } }

57:08

Next we can can integrate the alert behavior into the detail feature’s behavior using the ifLet operator again: .ifLet(\.$alert, action: /Action.alert) { }

57:35

But, what should we put in the body of the trialing closure?

57:38

Well, alerts, unlike sheets, popovers, and other forms of navigation, do not have internal logic and behavior. All that you can do with them is tap a button in the alert, and that causes the alert to go away.

57:56

So, we can actually just leave the trailing closure off this ifLet : .ifLet(\.$alert, action: /Action.alert) Note that the ifLet is still necessary because it does do the bare minimum of integration work required for alerts. In particular, when you interact with any button in an alert, the alert needs to be dismissed. This means the dismiss presentation action will be sent, and the alert state will be nil ’d out.

58:05

OK, that is the basics of integrating an alert into the reducer layer of the detail feature, let’s move onto the view.

58:12

Just as we have a special sheet(store:) operator, there is the same for alert . You hand it a store scoped to some presentation domain, and SwiftUI handles the rest. .alert( store: self.store.scope( state: \.$alert, action: { .alert($0) } ) )

58:46

And if we run the preview and tap “delete,” the alert shows. And we can use the ._printChanges() reducer operator to verify that all the actions are feeding through the store.

59:37

So everything is working great, and may work just fine for a little while, but as you have more destinations you can navigate to, modeling the destinations as multiple optional values causes a lot of uncertainty to seep into your code.

1:00:02

The problem is that using multiple optional values to represent what is mutually exclusive states leads to an explosion of invalid states. For example, right now it is completely possible for us to accidentally populate the editStandup state at the same time as the alert state: state.alert = … state.editStandup = …

1:00:19

But it makes no sense to show both an alert and an edit sheet at the same time. And in isolation this mistake may seem obvious, but in a much larger, more complex feature it may not be so clear what child features are capable of being presented at the same time and which aren’t, and you could easily mutate state in an invalid way.

1:00:59

Not to mention that it now becomes hard to know when something is actually being presented. If we have special logic we want to execute when neither an alert or sheet is presented, then we have to check both pieces of state. And if a 3rd type of navigation is added later we will have to update all of our code to check 3 pieces of state. Next time: enum presentation state

1:01:42

And as more types of navigation are added, the number of invalid states really explodes. Four navigation destinations have only 5 valid states, all nil or exactly one non- nil , yet for optionals have 16 possible states. So 70% of the states are completely invalid. And if there were 5 navigation destinations, then over 90% of the states would be invalid! Stephen

1:02:00

What we are seeing here is that representation multiple navigation destinations with multiple optionals is just not the right way to handle this. Luckily Swift has an amazing tool for dealing with this situation, and it is enums! Enums are the perfect tool for representing the mutually exclusive choice of one thing from many, which is exactly what we want here.

1:02:28

With just a little bit of upfront work we can refactor our domain to use enums instead of many optionals, and the code will become clearly, more succinct and safer.

1:02:38

So let’s give it a shot…next time! References Composable Architecture Brandon Williams & Stephen Celis • May 4, 2020 The Composable Architecture is a library for building applications in a consistent and understandable way, with composition, testing and ergonomics in mind. http://github.com/pointfreeco/swift-composable-architecture Getting started with Scrumdinger Apple Learn the essentials of iOS app development by building a fully functional app using SwiftUI. https://developer.apple.com/tutorials/app-dev-training/getting-started-with-scrumdinger Downloads Sample code 0246-tca-tour-pt4 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 .