Video #245: Tour of the Composable Architecture: Navigation
Episode: Video #245 Date: Aug 14, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep245-tour-of-the-composable-architecture-1-0-navigation

Description
With the standups list and standup form features ready, it’s time to integrate them together using the Composable Architecture’s navigation tools. We will make it so you can add and edit standups via a sheet, and write comprehensive unit tests for this integration.
Video
Cloudflare Stream video ID: 9365aca66e2c9ce5a26c3a8f2cf14f05 Local file: video_245_tour-of-the-composable-architecture-1-0-navigation.mp4 *(download with --video 245)*
References
- Discussions
- Composable Architecture
- Getting started with Scrumdinger
- CasePaths
- 0245-tca-tour-pt3
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
And this tool really is thanks to the fact that we are using value types for our domain. If we were using reference types this just would not be possible because we couldn’t capture a copy of state before running the action, and then compare to state after running the action.
— 0:18
OK, so this is all looking great. There are probably a few more tests that could be written, but let’s start moving onto more exciting things. Brandon
— 0:24
Right now we have two features built in the Composable Architecture, the standups list and the standup form, and they are supposed to be intimately related. We are supposed to be able to present the form view from the list view in a sheet when the “Add” button is tapped, and then further facilitate communication between these features to add a standup to the root list.
— 0:44
Let’s start doing that work, and get our first peek into what navigation looks like in the Composable Architecture. Adding a standup
— 0:51
Right now I am in the StandupsListFeature reducer, and it is quite bare right now. It just holds a collection of standups, there’s a single action, and currently we just have some temporary logic in place for that action. We are currently adding a new standup to the list with a random theme whenever the “Add” button is tapped.
— 1:07
But what we really want to happen is for a sheet to come flying up with the standup form so that we can enter the details of a new standup. And then we can choose to save that standup or cancel adding the standup.
— 1:22
Well, the first step to implementing the navigation from one feature to another starts with a domain modeling exercise. To represent navigating to the form feature from the list feature, we need a way of represent the child domain in the parent domain, and this goes for state and actions.
— 1:43
One way to do this is through the use of optionals. We can hold onto a piece of optional StandupFormFeature.State inside StandupsListFeature.State in order to represent whether or not the form is presented: struct State { var addStandup: StandupFormFeature.State? … }
— 2:03
Note that from a domain modeling viewpoint we don’t care how the form is presented. It could be in a sheet, popover, fullscreen cover, or even something custom. As far as the domain is concerned it just wants to know whether or not the feature is presented.
— 2:20
Now, we could technically proceed this way, but there are tools that come with the library that help us make the connections between child and parent domain even stronger. For the state, we can annotate this addStandup field with the PresentationState property wrapper: struct State { @PresentationState var addStandup: StandupFormFeature.State? … }
— 2:40
For all intents and purposes presentation state behaves like a regular optional, but it hides away a bit of extra information that can unlock some real super powers from the library, which we will see a bit later.
— 2:52
Next we need to represent the child feature’s actions in the parent feature. This can be done naively by just adding a new case to hold StandupFormFeature.Action : enum Action { … case addStandup(StandupFormFeature.Action) }
— 3:14
But, just as we did with presentation state, there is another tool to improve this. We can wrote the action in a PresentationAction : enum Action { … case addStandup( PresentationAction<StandupFormFeature.Action> ) }
— 3:27
PresentationAction is just a slim wrapper around an existing action that just adds one additional case that can be sent to dismiss a presented feature: public enum PresentationAction<Action> { case dismiss indirect case presented(Action) }
— 3:37
SwiftUI will automatically send the dismiss action when the user swipes down on the sheet to get rid of it, and we can even pattern match on the dismiss action in the reducer to be notified when something from the UI closes the feature.
— 4:01
That’s all it takes to integrate the domains of the parent and child features together. Next we have the reducer. We still have some logic in here that we sketched out earlier. We had it so that tapping the “Add” button just immediately added a standup to the list with a random color. Now we can start implementing some of the real logic.
— 4:17
When the “Add” button is tapped, we don’t want to immediately add a standup to the array, but instead we want to show a sheet with the form view. But as far as the domain is concerned, that simply means mutating the addStandup state to be populated with some data: case .addButtonTapped: state.addStandup = StandupFormFeature.State( standup: Standup(id: UUID()) ) return .none
— 4:43
SwiftUI will observe when this state flips from nil to non- nil and show the sheet.
— 4:50
And if we’ve learned anything from testing the form feature, it is best to not generate random UUIDs in our feature’s, and so let’s go ahead and control that dependency: @Dependency(\.uuid) var uuid
— 5:04
And use it when creating the fresh standup to pass along to the form: case .addButtonTapped: state.addStandup = StandupFormFeature.State( standup: Standup(id: self.uuid()) ) return .none
— 5:07
That’s about all the logic we need to implement right now in the core reducer, but there will be more things to layer on in the future.
— 5:14
There is also a new action to handle now, but for now we can just do nothing: case .addStandup: return .none
— 5:20
If in the future we needed to perform some integration logic between the list domain and the form domain, we would do it inside this action.
— 5:31
Now that we have the core reducer in good shape, how do we integrate the StandupFormFeature reducer into it? Well, the library comes with a tool to integrate features together. It’s a reducer operator called ifLet that can be applied to our core reducer: var body: some ReducerOf<Self> { Reduce { state, action in … } .ifLet( <#WritableKeyPath<State, PresentationState<_>>#>, action: <#CasePath<Action, PresentationAction<_>>#>, destination: <#T##() -> Reducer#> ) } You just supply it 3 pieces of information, and it then it will take care of safely unwrapping the optional state, applying the child reducer, managing effects, and a whole lot more.
— 6:05
The first argument is a key path that singles out the presentation state that drives the navigation, which is \.$addStandup : var body: some ReducerOf<Self> { Reduce { state, action in … } .ifLet( \.$addStandup, action: <#CasePath<Action, PresentationAction<_>>#>, destination: <#() -> Reducer#> ) }
— 6:17
The second is what is known as a “case path” that singles out the presentation action for the child feature. Case paths are a concept we introduced over 3 and a half years ago, and they are like key paths, which are great for isolating a field from a struct, but they instead allow you to isolate a case of an enum.
— 6:36
One constructs case paths using a syntax that is somewhat similar to key paths. You just use a forward slash instead of a backslash, and you specify the full enum type and case: var body: some ReducerOf<Self> { Reduce { state, action in … } .ifLet( \.$addStandup, action: /Action.addStandup, destination: <#() -> Reducer#> ) }
— 6:51
In a near future you will even be able to use Swift macros for specify the case path in a more familiar, key path-like syntax: .ifLet(\.$addStandup, action: #casePath(\.addStandup))
— 7:13
But we are still working on that tool, so we can’t do that right now.
— 7:15
And the final argument is a trailing closure where you specify the reducer to run on the domain that is singled out by the key path and case path. In particular, the StandupForm reducer: var body: some ReducerOf<Self> { Reduce { state, action in … } .ifLet(\.$addStandup, action: /Action.addStandup) { StandupFormFeature() } }
— 7:37
And that is all it takes to integrate the parent and child features, and it instantly opens up a very simple form of parent-child communication. If the parent wants to see what is happening in the child domain, it can simply destructure whatever action it wants. For example, if the parent wanted to know when the “Add attendee” button was tapped, for whatever reason, it could simply do: case .addStandup(.presented(.addAttendeeButtonTapped)): // Do something
— 8:07
In there we can perform whatever logic we want. And the best part is that whatever we do in here, it will all be testable. We don’t have to jump through any extra hoops to test how parent and child domains communicate with each other.
— 8:41
OK, so we are now done with the reducer side of integrating parent and child domains together for navigation. Now let’s handle the view layer.
— 8:55
We want to present a sheet when the addStandup state flips from nil to non- nil , and the way you would typically handle this in a vanilla SwiftUI application is via the sheet modifier that takes a binding to an optional, identifiable value: .sheet( item: <#Binding<Identifiable?>#>, content: <#(Identifiable) -> View#> ) We can’t use this API because we have an entire store wrapping our feature, not just a single piece of identifiable state.
— 9:04
Well, luckily the library comes with a tool that looks like the sheet modifier, but it takes a store focused in on presentation state and actions, and transforms it into a store of child state and actions: .sheet( store: <#Store<PresentationState<State>, PresentationAction<Action>>#>, content: <#(Store<State, Action>) -> View#> )
— 9:14
So, we just need to somehow scope our store down from the full standups list domain, down to just the presentation domain for the form feature. [00:009:36] And there is a tool to accomplish this called scope . It is a method on Store that allows you to derive a whole new store from an existing one that is focused on just one part of the domain: .sheet( store: self.store.scope( state: <#(StandupsListFeature.State) -> ChildState#>, action: <#(ChildAction) -> StandupsListFeature.Action#> ), content: <#(Store<State, Action>) -> View#> )
— 9:53
You do this by supply two arguments. A state argument that describes how to pluck the child state from the parent state, and an action argument that describes how to embed the child actions into the parent actions.
— 10:12
This can be done by providing the \.$addStandup key path and a closure for embedding the action into the addStandup case of the action enum: .sheet( store: self.store.scope( state: \.$addStandup, action: { .addStandup($0) } ) ) { store in StandupFormView(store: store) }
— 10:35
And that’s all it takes to integrate the parent and child views. There’s still a little more work to do to actually implement the feature, but the integration work is done.
— 11:24
We can even run the standups list feature in a preview to see that indeed when we tap the “Add” button a sheet comes flying up. And right now the only way to dismiss it is by swiping down on the sheet.
— 11:35
We can also see what is happening behind the scenes by tacking on a _printChanges to the reducer in the preview: StandupsListFeature() ._printChanges()
— 11:46
…and then opening the sheet and swiping to dismiss. We will see the following actions were sent with their respective state changes: received action: StandupsListFeature.Action.addButtonTapped StandupsListFeature.State( - _addStandup: nil, + _addStandup: StandupFormFeature.State( + _focus: .title, + _standup: Standup( + id: UUID(45B15CF6-7A42-44FC-BD1C-02C0051ABC7C), + attendees: [ + [0]: Attendee( + id: UUID(4CA6B37E-1CDF-494E-87CB-70E0AFA915DA), + name: "" + ) + ], + duration: 1 minute, + meetings: [], + theme: .bubblegum, + title: "" + ) + ), standups: […] received action: StandupsListFeature.Action.addStandup(.dismiss) StandupsListFeature.State( - _addStandup: StandupFormFeature.State( - _focus: .title, - _standup: Standup( - id: UUID(45B15CF6-7A42-44FC-BD1C-02C0051ABC7C), - attendees: [ - [0]: Attendee( - id: UUID(4CA6B37E-1CDF-494E-87CB-70E0AFA915DA), - name: "" - ) - ], - duration: 1 minute, - meetings: [], - theme: .bubblegum, - title: "" - ) - ), + _addStandup: nil, standups: […] )
— 11:50
So we can clearly see when the addButtonTapped action was sent the addStandup state was populated. And then when we swiped away, the .addStandup(.dismiss) action was sent, causing the state to be nil ’d out.
— 12:26
But, of course we don’t want swiping to be the only way to close the sheet, so let’s improve that real quick. We can wrap the standup form in a navigation stack so that we will have access to a navigation bar to stick some buttons. But where should we insert the stack view? We could put it directly in the form view: struct StandupFormView: View { … var body: some View { NavigationStack { … } } }
— 12:50
But this isn’t ideal. This is forcing the form to always be at the root of a navigation stack, but someday in the future it may want to be presented in other contexts for which that does not make sense. For example, it may want to be presented in an existing navigation stack.
— 13:02
It is better for the presenter of the form view to decide the surrounding context: .sheet( store: self.store.scope( state: \.$standupForm, action: { .standupForm($0) } ) ) { store in NavigationStack { StandupFormView(store: store) } }
— 13:14
Now we can provide situation specific configuration to this view, such as its title: .sheet( store: self.store.scope( state: \.$standupForm, action: { .standupForm($0) } ) ) { store in NavigationStack { StandupFormView(store: store) .navigationTitle("New standup") } }
— 13:19
This leaves us open to using a different title in other situations, such as when editing the standup.
— 13:26
Further, we will want to put “Save” and “Cancel” buttons in the toolbar of the navigation bar. Where should that go?
— 13:32
We could tack the toolbar onto the end of the StandupFormView like this: struct StandupFormView: View { … var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in Form { … } .bind(viewStore.binding(\.$focus), to: self.$focus) .toolbar { … } } } }
— 13:40
But that’s not quite right, for the same reasons it wasn’t right to stick the NavigationStack in this view. This form view is actually going to be reused when we get to editing standups, and so we will want to be able to customize the logic executed when the save button is tapped.
— 14:02
So, we feel that the best place to put this logic is in the parent view: .sheet( store: self.store.scope( state: \.$standupForm, action: { .standupForm($0) } ) ) { store in NavigationStack { StandupFormView(store: store) .navigationTitle("New standup") .toolbar { ToolbarItem { Button("Save") {} } ToolbarItem(placement: .cancellationAction) { Button("Cancel") {} } } } } This allows the StandupFormView to be reused in many different places without making it unnecessarily specific to one use case.
— 14:15
But, we now have new buttons in our UI, which means new actions to send to the store. Let’s sketch out what those actions would look like directly in the view: NavigationStack { StandupFormView(store: store) .navigationTitle("New standup") .toolbar { ToolbarItem { Button("Save") { viewStore.send(.saveStandupButtonTapped) } } ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewStore.send(.cancelStandupButtonTapped) } } } }
— 14:30
And then let’s add those actions to the Action enum: enum Action { … case cancelStandupButtonTapped case saveStandupButtonTapped }
— 14:44
And we will handle those actions in the reducer. In particular, when the “Cancel” button is tapped we will simply nil out the standupForm data in order to signify that the sheet should be dismissed: case .cancelStandupButtonTapped: state.addStandup = nil return .none
— 15:01
And when the “Save” button is tapped we will append the new standup to the array of standups, and we will dismiss the sheet: case .saveStandupButtonTapped: guard let standup = state.addStandup?.standup else { return .none } state.standups.append(standup) state.addStandup = nil return .none
— 15:38
And with that little bit of work everything works exactly as we would expect. We can run the app in the preview, tap the “Add” button, see the sheet fly up, make some changes to the form, and then hit “Save” to see the sheet dismiss and the standup added to the root list view.
— 15:55
Let’s also go ahead and install the standup list in the entry point of the application so that we can finally run it in the simulator: @main struct StandupsApp: App { var body: some Scene { WindowGroup { NavigationStack { StandupsListView( store: Store( initialState: StandupsListFeature.State() ) { StandupsListFeature() } ) } } } }
— 16:26
Now we can the app in the simulator, and it behaves just as it did in the preview. Of course, we don’t yet have persistence, so next time we launch the simulator the data is gone, but we will take care of that later.
— 16:49
But, we do have an opportunity to show off yet another super power of the Composable Architecture. Because all of navigation is modeled in state, and because it’s provided to views explicitly, via the Store , it is possible to instantly deep link into any state of our application.
— 17:06
For example, if we want to launch the application with the “Add standup” sheet up, we can simply populate the state necessary and let SwiftUI take care of the rest: @main struct StandupsApp: App { var body: some Scene { WindowGroup { NavigationStack { StandupsListView( store: Store( initialState: StandupsListFeature.State( addStandup: StandupFormFeature.State( standup: Standup(id: UUID()) ) ) ) { StandupsListFeature() } ) } } } }
— 17:28
Now when we launch the app we instantly see the sheet come flying up.
— 17:38
We can even deep link into a state where a specific field of the standup form is pre-focused. All we have to do is populate the corresponding piece of state: @main struct StandupsApp: App { var body: some Scene { WindowGroup { NavigationStack { StandupsListView( store: Store( initialState: StandupsListFeature.State( standupForm: StandupFormFeature.State( focus: .attendee(Standup.mock.attendees[2].id), standup: .mock ) ) ) { StandupsListFeature() } ) } } } }
— 18:06
Now when we launch the app, the sheet comes flying up, and the 3rd attendee’s text field is already focused. That’s pretty incredible. We have full control over our application thanks to the little bit of upfront work we did to properly model our domains, and keep state out of the views. Had we used tools like @State and @StateObject , then we would not be able to do things like this. Those tools localize state to live only in the view, and you don’t get the ability to easily control state from the outside. Scrumdinger retrospective
— 18:44
So, what we’ve just accomplished is pretty great. We now have two independently built features, but have integrated them together so that you can navigate from one to the other. And the parent feature is able to immediately inspect what is happening inside the child feature so that it can layer on additional, integration logic. Stephen
— 19:00
Sure it took a few steps to implement this integration, but it’s extremely powerful once done, and luckily we have the compiler helping us each step of the way. We use the ifLet operator in the reducer and the compiler tells us exactly what types we need to provide, and we use the sheet operator in the view, and again the compiler tells us exactly what is needed.
— 19:19
It’s worth taking a moment to compare what we have done here with the Composable Architecture against what Apple did in their Scrumdinger application.
— 19:28
If we go to their project and look up the ScrumsView , which is like our StandupsListView , we will see the following: struct ScrumsView: View { @Binding var scrums: [DailyScrum] @Environment(\.scenePhase) private var scenePhase @State private var isPresentingNewScrumView = false let saveAction: ()->Void … }
— 19:37
They represent the “New scrum” view being represented as a single piece of boolean state, and it’s even @State so it is completely local to this view.
— 19:46
They flip this state to true when the “New scrum” button is tapped: Button(action: { isPresentingNewScrumView = true }) { … }
— 19:51
And then that boolean is used to drive a sheet: .sheet(isPresented: $isPresentingNewScrumView) { NewScrumSheet(scrums: $scrums, isPresentingNewScrumView: $isPresentingNewScrumView) }
— 19:56
Interestingly it defines a whole separate view for facilitating showing the sheet, called NewScrumSheet .
— 20:03
If we go to that view we will find it holds onto more local @State and some bindings that connect its data to the parent: struct NewScrumSheet: View { @State private var newScrum = DailyScrum.emptyScrum @Binding var scrums: [DailyScrum] @Binding var isPresentingNewScrumView: Bool … }
— 20:12
And this view’s body holds all of the view hierarchy that we were able to inline directly into the parent in the Composable Architecture version. We didn’t need to create this separate view.
— 20:21
And the whole reason this view exists is to create create a little bit of scratch state, newScrum , that exists only for the lifetime of the sheet view. Older versions of Scrumdinger didn’t have this view and there was glitchy behavior where when you dismissed the sheet its contents would clear out as the sheet was animating away. We think the NewScrumSheet view was created entirely to work around that bug, that is quite strange. If you come across this code 6 months from now are you really going to understand what purpose this intermediate view serves?
— 20:51
You must always remember that the only true purpose for this view is to work around glitchiness that occurs due to an inaccurately modeled domain. There are two pieces of state needed to model the presentation of the sheet: @State private var newScrum = DailyScrum.emptyScrum @Binding var isPresentingNewScrumView: Bool
— 21:03
A boolean and a scratch piece of non-optional state that can be edited in the form.
— 21:09
As we’ve discussed many times in past Point-Free episodes, it is not ideal to model navigation state as a boolean plus a piece of state. It is strange for the boolean to be false , meaning the sheet is not presented, while also holding onto a piece of data. What does that data represent at that moment?
— 21:26
That leads one to defining things like the “empty scrum”: static var emptyScrum: DailyScrum { DailyScrum( title: "", attendees: [], lengthInMinutes: 5, theme: .sky ) }
— 21:30
…since the state is non-optional and so something must be put into the field.
— 21:34
These kinds of special, contextual values are historically known as “sentinel” values. In programming languages with type systems weaker than Swift’s, or no type system at all, a special “sentinel” value would be returned when an honest value could not be returned.
— 21:47
For example, if we forgot for a moment that Swift had optional types, and we tried to write a method on arrays that returned the index of an element, we might do so like this: extension Array where Element: Equatable { func indexOf(element: Element) -> Int { for index in self.indices { if self[index] == element { return index } } return -1 } }
— 21:56
Here we are returning -1 to represent that the element could not be found since that can never be the index in an array, and then it would be the responsibility of the caller to check the index to see if it is valid or not.
— 22:12
The compiler cannot help with that at all. As far as the compiler is concerned the method says it returns an integer, and so any integer is perfectly valid. As the user of this API you will have to read the docs or the source to know that it can return a -1 and that you should proactively check the value when calling the method.
— 22:30
But this is of course not how we would write this method today. It is a relic of a time long ago, before stronger type systems and wonderful support for optionals and enums, which are some of the biggest features Swift brought when it was first released 9 years ago.
— 22:42
The better way of implementing the method is to use optionals: extension Array where Element: Equatable { func indexOf(element: Element) -> Int? { for index in self.indices { if self[index] == element { return index } } return nil } }
— 22:55
Then the caller of the function has a clear signal that sometimes this method may not be able to produce an index, and the compiler has your back each step of the way. If you tried to add 1 to the value returned from this method you would instantly be met with a compiler error letting you know that is not possible. It will force you to do an if let , or guard let , or map , or even a force unwrap if you truly want, in order to use the return value as an integer.
— 23:17
Now you might wonder, why wasn’t the editing scrum modeled as an optional instead of the two separate pieces of state, like this: @State private var newScrum: DailyScrum? // @State private var newScrum = DailyScrum.emptyScrum // @State private var isPresentingNewScrumView = false
— 23:35
Then nil would represent the sheet is not presented, and a non- nil value would represent it is presented. And SwiftUI even ships with a sheet API that deals with optional data.
— 23:45
Well, the reason is because that sheet API does not allow presenting a sheet with a binding: .sheet(item: self.$newScrum) { $newScrum in … }
— 24:01
If we could derive a binding to non-optional state from a binding of optional state, then we could hand that down to the child view allowing any mutations the child view makes to be instantly observable by the parent. But, that’s not the case. Instead, the trailing closure is just handed an inert value with no connection to the parent whatsoever.
— 24:20
That is the sole reason this domain is modeled as a boolean plus some state. It is specifically so that the parent view and child view can both have access to the binding: @State private var newScrum = DailyScrum.newScrum @State private var isPresentingNewScrumView = false
— 24:35
So, it is kind of interesting that we are going back this the less-than-ideal style of domain modeling using sentinel values. Even though Swift gives us many of the tools we need to model our domains as concisely as possible, SwiftUI often does not take full advantage of those tools. And so care needs to be taken to properly clear out that data when the sheet is dismissed, or we need to maintain these strange intermediate views to do it for us, and that kind of uncertainty starts to leak into every part of your application.
— 25:01
It’s also worth noting that since all of this state is modeled locally with @State we will have no way to deep link into the “add scrum” screen. That ability is completely hidden from us since @State creates its own local source of truth, and cannot be influenced from the outside. But we just saw a moment ago that deep linking was incredibly easy in the Composable Architecture version. It was simply a matter of configuring the store with some state, and letting SwiftUI handle the rest.
— 25:50
So sure we did have to perform a few steps to get all the domains connected, but once that was done we got a lot of benefits out of the tools. Testing the standups list Brandon
— 25:56
We still have a few more screens to build, but before moving on let’s write some tests for our standups list feature. There isn’t a ton of logic in the feature, and it’s a good habit to write tests early and often.
— 26:09
Let’s create a new file and paste in some basic scaffolding: import ComposableArchitecture import XCTest @testable import Standups @MainActor final class StandupsListTests: XCTestCase { }
— 26:23
There’s only one real piece of logic in this feature that we want to test, which is the full user flow of a user tapping on the “Add” button, the sheet is presented, the user interacts with the form in some way, like say changing the title of the standup, and then tapping save. At that point we would want to confirm that a new standup was added to the root list and that the sheet was dismissed.
— 26:42
So, let’s give that a shot.
— 26:45
We’ll start up a new test method: func testAddStandup() async { }
— 26:49
Then we’ll create a test store for the standups list feature, and I think we are going to be using the UUID dependency since new standup values will be created, so I will go ahead and override that: let store = TestStore( initialState: StandupsListFeature.State() ) { StandupsListFeature() } withDependencies: { $0.uuid = .incrementing }
— 26:57
But to do that we need to make the StandupsListFeature.State equatable: struct State: Equatable { … }
— 27:06
Now we are really to start sending actions into the test store to emulate what the user does in the app, and each step of the way we can assert on what is happening.
— 27:17
For example, we can start by emulating the user tapping the “Add” button: await store.send(.addButtonTapped) { }
— 27:25
And now inside the trailing closure we will assert that when that happens the addStandup state is populated with some fresh data. In particular, we know that a new standup will be created, and further, thanks to the validation logic we have inside the initializer of StandupFormFeature.State , the standup will be given a new attendee: await store.send(.addButtonTapped) { $0.addStandup = StandupFormFeature.State( standup: Standup( id: UUID(0), attendees: [Attendee(id: UUID(1))] ) ) }
— 28:44
This nearly passes, but we do have one small failure: A state change does not match expectation: … StandupsListFeature.State( _addStandup: StandupFormFeature.State( _focus: .title, _standup: Standup( id: UUID(00000000-0000-0000-0000-000000000000), attendees: [ [0]: Attendee( − id: UUID(00000000-0000-0000-0000-000000000001) + id: UUID(AB8E7993-C322-437A-AD55-6AC7A998A75A) name: "" ) ], duration: 1 minute, meetings: [], theme: .bubblegum, title: "" ) ), standups: [] ) (Expected: -, Actual: +)
— 28:53
Looks like we have another sneaky uncontrolled UUID initializer somewhere.
— 29:03
This is in initializer of the StandupFormFeature state where we had sprinkled in some extra logic to force there to be at least one attendee when creating the state: init(focus: Field? = .title, standup: Standup) { self.focus = focus self.standup = standup if self.standup.attendees.isEmpty { self.standup.attendees.append( Attendee(id: UUID()) ) } }
— 29:12
We can control this dependency right in the initializer: init(focus: Field? = .title, standup: Standup) { self.focus = focus self.standup = standup if self.standup.attendees.isEmpty { @Dependency(\.uuid) var uuid self.standup.attendees.append(Attendee(id: uuid())) } }
— 29:23
And now when we run the test it passes!
— 29:26
Next let’s emulate something happening inside the standup form feature. Like say, the user enters a new title for the standup. We can use autocomplete to have a conversation with the compiler to figure out precisely what action we need to use.
— 29:56
First we know we want to send an action in the addStandup domain, so we can start there: await store.send( .addStandup( <#PresentationAction<StandupFormFeature.Action>#> ) )
— 30:02
Then we can use autocomplete to see that next we want to send an action when the standup form is presented: await store.send( .addStandup(.presented(<#StandupFormFeature.Action#>)) )
— 30:07
And then within the standup domain we want to update the standup with a new name applied, which can be done by sending the .set action: await store.send( .addStandup( .presented( .set( <#WritableKeyPath<StandupFormFeature.State, BindingState<V>>#>, <#V#> ) ) ) )
— 30:20
We will specify the key path to the binding state we want to update, which is the standup: await store.send( .addStandup(.presented(.set(\.$standup, <#V#>))) )
— 30:25
And we need to provide a standup that is identical to what is already in state, but with the title updated. So we can extract out the standup we asserted on when the add button was tapped: var standup = Standup( id: UUID(0), attendees: [Attendee(id: UUID(1))] ) await store.send(.addButtonTapped) { $0.addStandup = StandupFormFeature.State( standup: standup ) }
— 30:38
Update the title: standup.title = "Point-Free Morning Sync"
— 30:48
And send that in the action: await store.send( .addStandup(.presented(.set(\.$standup, standup))) )
— 30:50
And we expect the feature’s state to update in the following way: await store.send( .addStandup(.presented(.set(\.$standup, standup))) ) { $0.addStandup?.standup.title = "Point-Free Morning Sync" }
— 31:11
And this test passes!
— 31:37
Finally we will emulate the tapping the “Save” button: await store.send(.saveStandupButtonTapped) { }
— 31:49
…which we expect to cause that standup from the form to be added to the root collection of standups: await store.send(.saveStandupButtonTapped) { $0.standups[0] = Standup( id: Standup.ID(UUID(0)), attendees: [Attendee(id: Attendee.ID(UUID(1)))], title: "Point-Free Morning Sync" ) }
— 32:29
However, if we run the test it fails: A state change does not match expectation: … StandupsListFeature.State( − _standupForm: StandupForm.State( − _focus: .title, − _standup: Standup( − id: UUID(00000000-0000-0000-0000-000000000000), − attendees: [ − [0]: Attendee( − id: UUID(00000000-0000-0000-0000-000000000001), − name: "" − ) − ], − duration: 1 minute, − meetings: [], − theme: .bubblegum, − title: "Point-Free" − ) − ), + _standupForm: nil, standups: […] ) (Expected: -, Actual: +)
— 32:49
We forgot that the sheet is dismissed when saving, and therefore we should nil out the addStandup state: await store.send(.saveStandupButtonTapped) { $0.addStandup = nil $0.standups[0] = Standup( id: UUID(0), attendees: [Attendee(id: UUID(1))], title: "Point-Free Morning Sync" ) }
— 33:04
And now the test passes!
— 33:05
It is amazing to see how the test store has our back each step of the way to make sure we are asserting on everything that is happening in the system. You may have found it annoying that the test failed until we asserted on addStandup being nil , but you definitely would not be annoyed if we later introduced a bug that caused the addStandup state to not be nil ’d out and that caused a test failure. Because then we would have a very serious bug in our application, one which prevents the sheet from being dismissed, and of course you would want your test suite to notify you of such a situation.
— 33:41
That is the power of exhaustive testing. It helps you uncover very subtle and nuanced bugs that might creep into your application. However, exhaustive testing isn’t always appropriate. The more you test your root application features, which can be composed of many other features, the more annoying it gets. You have to assert on every little thing happening in each child feature even if all you want to verify is that some overall state at the root changes. And this creates very brittle tests in which any change of logic or behavior in a child feature can cause many tests to fail, even if those tests don’t really care about the child behavior.
— 34:22
This is why the test store has an alternative mode, called “non-exhaustive testing”, which allows you to assert on only the parts you care about. We find it to be a wonderful tool for testing the integration of many features, whereas exhaustive testing works best on single features in isolation. And there may be times you want an exhaustive test on the integration of many features just to get some broad, deep test coverage, but it’s more of the exception than the rule.
— 34:49
So, let’s write this test again, but using a non-exhaustive test store so that we can assert on just the behavior that a standup is eventually added to the root collection of standups. We don’t care about any other state changes.
— 35:06
I’ll start by copying and pasting the previous test, but remaining the test: func testAddStandup_NonExhaustive() async { … }
— 35:13
In order to put the test store into “non-exhaustive” mode we need to only need to make one change: store.exhaustivity = .off
— 35:18
After that point the send method on the test store works a little differently. Rather than handing you a $0 that represents the state before the action was sent, it represents state after the action was sent: await store.send(.addButtonTapped) { $0 // State after action was sent }
— 35:43
This means if you do nothing in this closure the test will pass just fine. But , if you do decide to perform a mutation in here, then it better match what was there already, or else you will get a test failure. For example, this: await store.send(.addButtonTapped) { _ = $0 }
— 35:47
…passes just fine. In fact, the trailing closure is even optional, so we could even leave it off: await store.send(.addButtonTapped)
— 35:55
And that’s exactly what we want to do because at this moment we don’t want to assert on the manner in which the form is presented or operates. We just want to confirm that once we go through all the steps, a standup is eventually added to the root collection.
— 36:04
So we will even drop the trailing closure off the next send : standup.title = "Point-Free Morning Sync" await store.send( .addStandup(.presented(.set(\.$standup, standup))) )
— 36:08
And for the final send we will only assert that the standup was added to the collection: await store.send(.saveStandupButtonTapped) { $0.standups[0] = Standup( id: Standup.ID(UUID(0)), attendees: [Attendee(id: Attendee.ID(UUID(1)))], title: "Point-Free Morning Sync" ) }
— 36:16
This test still passes even though we are asserting on a lot less. This allows us to focus in on just the tiny bit of behavior that we want to exercise without having to worry about everything happening in the entire system.
— 36:31
Now it’s important to know that we aren’t saying that one style of testing is better than the other. They each have their pros and cons, and in reality you will have a healthy mix of each in your test suite.
— 36:43
One final cool thing to show off before moving on. There’s another exhaustivity option you can provide: store.exhaustivity = .off(showSkippedAssertions: true)
— 36:53
This essentially behaves the same as a non-exhaustive test store, in that you still can assert on only the state changes you care about, but it also shows grey, informational callouts in Xcode on the parts that you did not exhaustive assert on changes.
— 37:09
So, in each of the send calls we get a callout letting us know exactly what state we did not assert on: Skipped assertions: … State was not expected to change, but a change occurred: … StandupsListFeature.State( − _standupForm: nil, + _standupForm: StandupForm.State( + _focus: .title, + _standup: Standup( + id: UUID(00000000-0000-0000-0000-000000000000), + attendees: [ + [0]: Attendee( + id: UUID(00000000-0000-0000-0000-000000000001), + name: "" + ) + ], + duration: 1 minute, + meetings: [], + theme: .bubblegum, + title: "" + ) + ), standups: [] ) (Expected: -, Actual: +) Skipped assertions: … State was not expected to change, but a change occurred: … StandupsListFeature.State( _standupForm: StandupForm.State( _focus: .title, _standup: Standup( id: UUID(00000000-0000-0000-0000-000000000000), attendees: […], duration: 1 minute, meetings: [], theme: .bubblegum, − title: "" + title: "Point-Free" ) ), standups: [] ) (Expected: -, Actual: +) Skipped assertions: … A state change does not match expectation: … StandupsListFeature.State( − _standupForm: StandupForm.State( − _focus: .title, − _standup: Standup( − id: UUID(00000000-0000-0000-0000-000000000000), − attendees: [ − [0]: Attendee( − id: UUID(00000000-0000-0000-0000-000000000001), − name: "" − ) − ], − duration: 1 minute, − meetings: [], − theme: .bubblegum, − title: "Point-Free" − ) − ), + _standupForm: nil, standups: […] ) (Expected: -, Actual: +)
— 37:29
Now we can very clearly see exactly what state we decided not to assert against, and this can be really handy for tracking down bugs.
— 37:32
Say you are seeing some buggy behavior in your application, but your entire test suite is passing. Well, you can look through these logs to see if you get any hints of what could be going wrong based on the state that you are not asserting against. Viewing and editing a standup
— 37:53
We’ve now got two full features under our belt, the standups list feature and the standup form feature. They are integrated together so that we can navigate to the form from the list. Deep linking is supported right out of the box with no additional work. And we have a pretty complete test suite that exercises some of the thornier parts of the application’s logic. Stephen
— 38:12
We still have quite a bit ahead of us, so let’s move onto the next feature. In Apple’s Scrumdinger app, when you tap a standup in the root list, you drill down to a new screen that shows all the details of the standup, and has a number of actions you can take. You can edit the standup, you can record a new meeting, you can see your past meetings, and you can delete the standup.
— 38:31
Building this feature is going to force us to come face-to-face with the two main flavors of navigation: tree-based navigation and stack-based navigation. Each is powerful in their own way, and has their own tradeoffs.
— 38:43
So, let’s see what it takes to build this new feature, and integrate it into the rest of the application.
— 38:50
Let’s create a new file for our feature.
— 38:56
And we are going to paste in the basic scaffolding for a SwiftUI view for the detail feature. This is mostly ripped straight from Apple’s Scrumdinger application: import ComposableArchitecture import SwiftUI struct StandupDetailView: View { var body: some View { List { Section { NavigationLink { <#Do something#> } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text(<#"5 min"#>) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(<#"Bubblegum"#>) .padding(4) .foregroundColor(<#.black#>) .background(<#Color.mint#>) .cornerRadius(4) } } header: { Text("Standup Info") } if <#!meetings.isEmpty#> { Section { ForEach(<#meetings#>) { meeting in NavigationLink { <#Do something#> } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } .onDelete { indices in <#Do something#> } } header: { Text("Past meetings") } } Section { ForEach(<#attendees#>) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete") { <#Do something#> } .foregroundColor(.red) .frame(maxWidth: .infinity) } } .navigationTitle(<#"Title"#>) .toolbar { Button("Edit") { <#Do something#> } } } } #Preview { MainActor.assumeIsolated { NavigationStack { StandupDetailView() } } }
— 39:09
We aren’t building this from scratch because there isn’t anything that interesting in here, and we are more focused on the logic and behavior of features rather than correctly wielding SwiftUI to make complex views.
— 39:19
And just like we did previously, we have a bunch of placeholders in this code of state that needs to be filled in or action closures that need to be implemented.
— 39:28
We can run the preview to see it does look reasonable. There are a lot of spots in the UI that we need to fill data in, and there are a lot of user actions that can be sent into the store. So, inspired by the UI, let’s start sketching out a domain and reducer for our feature.
— 39:40
Let’s start with a stub of a new Reducer conformance: struct StandupDetailFeature: Reducer { struct State { } enum Action { } var body: some ReducerOf<Self> { Reduce { state, action in switch action { } } } }
— 40:03
It seems that the vast majority of state displayed in the UI comes from a Standup model, and so we certainly are going to want one of those in state: struct State { var standup: Standup }
— 40:13
And just as with the StandupsList reducer, we are going to add more state to this to accommodate the various places we can navigate to from here, such as the edit screen for editing this standup. But we will get to that later.
— 40:22
There are also a number of user actions we can already accommodate by just looking at the preview. We see that there is an “Edit” button that can be tapped, a “Start meeting” button, a “Delete” button, and if we had some past meetings being shown we could even delete them.
— 40:36
This is because we have applied the onDelete view modifier to the ForEach that displays each meeting: ForEach(…) { meeting in NavigationLink { } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } .onDelete { indices in }
— 40:44
So, let’s get down each of those actions: enum Action { case deleteButtonTapped case deleteMeetings(atOffsets: IndexSet) case editButtonTapped }
— 41:03
And as of right now we aren’t really in a position to implement any of these actions, except for the delete action. So let’s do that, and return .none from the others: Reduce { state, action in switch action { case .deleteButtonTapped: return .none case let .deleteMeetings(atOffsets: indices): state.standup.meetings.remove(atOffsets: indices) return .none case .editButtonTapped: return .none } }
— 41:29
OK, now that we have the domain in place we can start hooking things up to the view. This consists of first adding a store to the view: struct StandupDetailView: View { let store: StoreOf<StandupDetailFeature> … }
— 41:41
Observing the store in the view so that we get access to state: var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in … } }
— 41:55
But first, before even being able to use WithViewStore , we need our state to be Equatable : struct State: Equatable { … }
— 42:03
Currently we are observing everything because all we have is a standup. But that isn’t going to be the correct choice long term. We are soon going to have other state in here that we do not need to observe, such as the standup form state for editing a standup. At that time we will want to introduce some view state so that we can chisel down to the bare essentials of state that the view actually needs to do its job.
— 42:21
Now we can start using state in various places in the view, and start sending actions from the various action closures: var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in List { Section { NavigationLink { <#Do something#> } label: { Label("Start Meeting", systemImage: "timer") .font(.headline) .foregroundColor(.accentColor) } HStack { Label("Length", systemImage: "clock") Spacer() Text( viewStore.standup.duration.formatted(.units()) ) } HStack { Label("Theme", systemImage: "paintpalette") Spacer() Text(viewStore.standup.theme.name) .padding(4) .foregroundColor( viewStore.standup.theme.accentColor ) .background(viewStore.standup.theme.mainColor) .cornerRadius(4) } } header: { Text("Standup Info") } if !viewStore.standup.meetings.isEmpty { Section { ForEach(viewStore.standup.meetings) { meeting in NavigationLink { <#Do something#> } label: { HStack { Image(systemName: "calendar") Text(meeting.date, style: .date) Text(meeting.date, style: .time) } } } .onDelete { indices in viewStore.send( .deleteMeetings(atOffsets: indices) ) } } header: { Text("Past meetings") } } Section { ForEach(viewStore.standup.attendees) { attendee in Label(attendee.name, systemImage: "person") } } header: { Text("Attendees") } Section { Button("Delete") { viewStore.send(.deleteButtonTapped) } .foregroundColor(.red) .frame(maxWidth: .infinity) } } .navigationTitle(viewStore.standup.title) .toolbar { Button("Edit") { viewStore.send(.editButtonTapped) } } } }
— 43:13
And finally we need to fix the preview to provide a store to the view: #Preview { NavigationStack { StandupDetailView( store: Store( initialState: StandupDetailFeature.State( standup: .mock ) ) { StandupDetailFeature() } ) } }
— 43:35
Now the preview looks a lot more alive. We can see past meetings and attendees. We can even delete the past meeting, and it works thanks to the simple logic we implemented in the reducer.
— 43:49
OK, we now have the very basics of a detail feature into place, but there is still a lot left to implement. Let’s start with the edit functionality. This should be pretty straightforward since we should be able to reuse the standup form feature. It provides the basics of a form that allows one to edit all the pieces of a standup. Further, we previously integrated the form into the standups list by presenting it in a sheet. The steps to do it should be quite similar to what we need to do here.
— 44:02
So, we’ll start by adding some presentation state to the detail state: struct State: Equatable { @PresentationState var editStandup: StandupFormFeature.State? … }
— 44:23
And we’ll add the presentation action to the Action enum: enum Action: Equatable { … case editStandup( PresentationAction<StandupFormFeature.Action> ) }
— 44:34
We can handle the case in the reducer. There’s nothing to do right now: case .editStandup: return .none
— 44:39
But we will update the editButtonTapped action since we can now populate the editStandup state to represent that the sheet should come flying up: case .editButtonTapped: state.editStandup = StandupFormFeature.State( standup: state.standup ) return .none
— 44:54
And finally we will integrate the reducers together by applying the ifLet operator just as we did in the standups list reducer: var body: some ReducerOf<Self> { Reduce { state, action in … } .ifLet(\.$editStandup, action: /Action.editStandup) { StandupFormFeature() } }
— 45:12
That is all that is required for the reducer layer. The features are now fully integrated together.
— 45:16
To integrate the views together all we have to do is tack on the sheet modifier at the end of the view that takes a store focused in on some presentation domain. This is exactly what we did in the standups list view, and we will do it again here. .sheet( store: self.store.scope( state: \.$editStandup, action: { .editStandup($0) } ) ) { store in StandupFormView(store: store) }
— 45:52
And that right there is already enough to get the sheet to appear in the view. But we have to do some more work to add the buttons in the tool bar for cancelling and saving changes.
— 46:00
We will accomplish this by wrapping the StandupFormView in a navigation stack so that we can attach some toolbar buttons: .sheet( store: self.store.scope( state: \.$editStandup, action: { .editStandup($0) } ) ) { store in NavigationStack { StandupFormView(store: store) .navigationTitle("Edit standup") .toolbar { ToolbarItem { Button("Save") {} } ToolbarItem(placement: .cancellationAction) { Button("Cancel") {} } } } }
— 46:10
Those new buttons mean we have new actions to send to the store: .toolbar { ToolbarItem { Button("Save") { viewStore.send(.saveStandupButtonTapped) } } ToolbarItem(placement: .cancellationAction) { Button("Cancel") { viewStore.send(.cancelEditStandupButtonTapped) } } }
— 46:20
Which means we have new cases to add to the Action enum: enum Action { case cancelEditStandupButtonTapped … case saveStandupButtonTapped }
— 46:30
And we can implement each of these cases quite easily. For the cancel action we simply need to nil out the editStandup state to represent dismissing the sheet: case .cancelEditStandupButtonTapped: state.editStandup = nil return .none
— 46:43
And the save action can be implemented by mutating the detail’s standup with the freshest one sitting in the editStandup state, as well as making sure to dismiss the edit sheet: case .saveStandupButtonTapped: guard let standup = state.editStandup?.standup else { return .none } state.standup = standup state.editStandup = nil return .none
— 47:11
And we now have a fully functional edit flow in the detail screen. In the preview we can tap the “Edit” button to see the sheet come flying up, we can make some changes, like say change the theme of the standup, and then hitting “Save” dismisses the sheet and we see the change applied to the standup in the detail screen.
— 47:23
It’s pretty amazing we can accomplish all of this while still using simple value types and with just a little bit of upfront work to model our domains correctly. The equivalent of this in vanilla SwiftUI can be quite messy.
— 47:33
Now that we have some real functionality in this screen, let’s get a basic test into place. We just want to get some test coverage on the flow of the user opening up the edit sheet, making some changes, and then hitting save. We’ll even do it in the non-exhaustive style: import ComposableArchitecture import XCTest @testable import Standups @MainActor final class StandupDetailTests: XCTestCase { func testEdit() async throws { var standup = Standup.mock let store = TestStore( initialState: StandupDetailFeature.State( standup: standup ) ) { StandupDetailFeature() } store.exhaustivity = .off await store.send(.editButtonTapped) standup.title = "Point-Free Morning Sync" await store.send( .editStandup(.presented(.set(\.$standup, standup))) ) await store.send(.saveStandupButtonTapped) { $0.standup.title = "Point-Free Morning Sync" } } }
— 48:13
That right there passes already, and we didn’t even have to do much work.
— 48:18
It of course doesn’t assert on everything happening in the feature. There could be bugs lurking in the shadows that this test will not catch, but it’s a good starting point. 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. Next time: Navigating to the detail
— 48:35
We now have yet another feature under our belts, that of seeing the details of a standup and even editing the standup. Brandon
— 48:42
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.
— 48:57
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.
— 49:24
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.
— 49:50
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.
— 50:00
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.
— 50:14
Let’s get started…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 CasePaths Brandon Williams & Stephen Celis CasePaths is one of our open source projects for bringing the power and ergonomics of key paths to enums. https://github.com/pointfreeco/swift-case-paths Downloads Sample code 0245-tca-tour-pt3 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 .