EP 229 · Composable Navigation · Apr 3, 2023 ·Members

Video #229: Composable Navigation: Correctness

smart_display

Loading stream…

Video #229: Composable Navigation: Correctness

Episode: Video #229 Date: Apr 3, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep229-composable-navigation-correctness

Episode thumbnail

Description

We now support many different forms of navigation in the Composable Architecture, but if used naively, they open us up to invalid states, like being navigated to several screens at a time. We’ll correct this with the help of Swift’s enums.

Video

Cloudflare Stream video ID: 2d8ac5cdd071ca8e7c863226c6b1e1ad Local file: video_229_composable-navigation-correctness.mp4 *(download with --video 229)*

References

Transcript

0:05

We now have a pretty incredible and comprehensive suite of navigation tools for the Composable Architecture. We can handle alerts, confirmation dialogs, sheets, popovers, covers, navigation links, and even a new iOS 16 style of navigation destinations. And it doesn’t matter which form of navigation you are trying to implement, the steps to do it are all basically the same. You do a bit of domain modeling, you integrate the parent and child features together using the ifLet reducer operator, and then you integrate the view by calling the corresponding SwiftUI view modifier.

0:37

There is of course a big, gaping hole in our navigation tools, and that is the new iOS 16 NavigationStack API that takes a binding. This style of navigation is extremely powerful, though sometimes difficult to handle correctly, and we will have a lot to say about that soon, but there is something more important to address first. Brandon

0:55

While what we have accomplished so far is pretty impressive, after all we have unified 6 different forms of navigation into essentially a single API, we are still modeling our domain in a less than ideal way. Our inventory feature is using 4 pieces of optional state to represent all the different places you can navigate to, and that allows for a lot of non-sensical states which leaks uncertainty into every corner of our codebase.

1:23

For example, it’s technically possible for the addItem and duplicateItem and editItem all be non- nil at the same time. What does that even mean? A sheet, popover, and drill-down would all activated at the same time? And beyond that weirdness we can also never be sure that we know exactly what screen is being displayed at any time. We have to check all 4 optionals to see if something is presented and if 2 or more things are non- nil we just have to guess at what is actually being displayed.

1:53

By using 4 optionals we technically have “2 to the 4th” possible states of things being nil or non- nil , which is 16 possibilities. Only 5 of those are valid: either they are all nil or exactly one is non- nil . That leads us to believe that a single enum is a much better tool for modeling this domain than a bunch of optionals, and that’s a lesson we learned when discussing vanilla SwiftUI navigation , as well as modern SwiftUI .

2:17

We put in a lot of effort to build up navigation tools in vanilla SwiftUI that allowed us to model all destinations as a single enum, and then derive bindings to each case of the enum in order to drive navigation. We need to do the same for the navigation tools we have just built for the Composable Architecture, so let’s see how we can accomplish that. Enum navigation

2:38

Let’s start by seeing why having so many optional pieces of state is problematic. What if we edited the entry point of the application so that both the addItem and duplicateItem state were populated at the same time: AppFeature.State( inventory: InventoryFeature.State( addItem: ItemFormFeature.State(item: .headphones), duplicateItem: .init(item: .headphones.duplicate()), items: [ .monitor, .mouse, .keyboard, .headphones ] ), selectedTab: .inventory )

3:17

What do we expect to happen when we launch the application? One piece of state represents a sheet and the other represents a popover. SwiftUI doesn’t support both being active at the same time, so will only one be active and the other silently ignored? Or will one be active and then when we close it the other will pop up?

3:33

Well let’s find out. If we launch the application we will see that the duplicate screen is up, so it looks like the popover won for initial display. I’m not sure if that’s deterministic or not, or if it depends on the order the view modifiers are applied in the view. Either way, there are some logs in the console that let us know that maybe something isn’t quite right: [Presentation] Attempt to present < TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView : 0x113856000> on < TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier _: 0x12001a600> (from < TtGC7SwiftUI32NavigationStackHostingControllerVS_7AnyView : 0x11384be00>) which is already presenting < TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView : 0x133832a00>.

4:02

It seems to be saying that something is being presented while something else is already being presented.

4:08

That may not seem so bad, however if we look at our logs we will see something is definitely wrong: received action: AppFeature.Action.inventory( .cancelDuplicateItemButtonTapped ) AppFeature.State( firstTab: FirstTabFeature.State(), inventory: InventoryFeature.State( addItem: ItemFormFeature.State(…), alert: nil, - duplicateItem: ItemFormFeature.State( - _isTimerOn: false, - _item: Item( - id: UUID( - 55837354-5B58-490F-B9D8-21644DA0529E - ), - name: "Headphones", - color: Item.Color( - name: "Blue", - red: 0.0, - green: 0.0, - blue: 1.0 - ), - status: .inStock(quantity: 20) - ) - ), + duplicateItem: nil, editItem: nil, items: […] ), selectedTab: .inventory, thirdTab: ThirdTabFeature.State() ) (00:04:32) can see that we received an action to cancel the popover, causing the duplicateItem state to be nil ’d out. That is correct, however what is not is correct is that it seems that the addItem state is still populated: addItem: ItemFormFeature.State(…),

4:45

This state is non- nil and yet a sheet is definitely not showing right now. So we have forced SwiftUI into an inconsistent state, and that is really bad. We can no longer trust that when a piece of state in our domain is non- nil that it represents the feature is being displayed on the screen.

5:02

So, this is clearly a problem, and it’s something that affects vanilla SwiftUI too. You can recreate this problem in SwiftUI without using the Composable Architecture at all. We should have the tools that allow us to better model our domains so that these non-sensical states just aren’t possible.

5:24

Let’s start by theorizing how we would want to handle enum presentation state in order to figure out the most ideal API, and then see if we can make that API a reality.

5:34

If you’ve watched any of our episodes on vanilla SwiftUI or modern SwiftUI , then you will know that we like to bundle up all the various places a feature can navigate to into a Destination enum. So perhaps we can just create an enum with a case for each of the 4 destinations, and in each case we just hold onto the state for that feature: enum Destination: Equatable { case addItem(ItemFormFeature.State) case alert(AlertState<InventoryFeature.Action.Alert>) case editItem(ItemFormFeature.State) case duplicateItem(ItemFormFeature.State) }

6:27

And then we could hold onto just a single piece of optional state that represents we are either not navigated anywhere, or we are navigating to a single destination: struct State: Equatable { var destination: Destination? var items: IdentifiedArrayOf<Item> = [] }

6:43

We’ve now traded 4 pieces of optional state for a single one. That would be pretty nice.

6:55

However, we also have actions to worry about. Features built in the Composable Architecture separate out the state that drives the UI from the actions that can take place in the UI, and it seems like we need a representation of that in this destination enum set up.

7:11

I guess we could set up an enum for all the destination actions: enum DestinationAction: Equatable { case addItem(ItemFormFeature.Action) case alert(InventoryFeature.Action.Alert) case duplicateItem(ItemFormFeature.Action) case editItem(ItemFormFeature.Action) }

7:47

And then we could get rid of 4 cases in the Action enum and replace it with a single destination case that holds onto a PresentationAction : enum Action: Equatable { case addButtonTapped case cancelAddItemButtonTapped case cancelDuplicateItemButtonTapped case confirmAddItemButtonTapped case confirmDuplicateItemButtonTapped case deleteButtonTapped(id: Item.ID) case destination(PresentationAction<DestinationAction>) case duplicateButtonTapped(id: Item.ID) case itemButtonTapped(id: Item.ID) }

7:57

So that would be pretty nice too. Now all child actions are coalesced into just a single case, and the only other actions in this enum are literal button tapes that can happen in the feature.

8:11

Now, it seems a little weird the state is called just Destination yet the action is called DestinationAction . Maybe we should rename the Destination enum to DestinationState : enum DestinationState: Equatable { … }

8:24

But now what’s interesting is that we have a type for the state of the destinations and a type for the actions of the destinations. That’s basically half the requirements for a Reducer conformance. So, should this be a reducer itself?

8:30

Instead of a DestinationState and DestinationAction enum, should we just wrap everything in a Destination reducer? struct Destination: Reducer { enum State: Equatable { … } enum Action: Equatable { … } }

8:51

Now we have a natural place to house the state and action enums. It’s not super common to have State be an enum, but there’s nothing wrong with that, and this is a totally valid time to do it.

9:11

Even better, we also have a natural place to house the integration of all the destination reducers into one single package. We can use the body property to compose things together, and we can use the version of the Scope reducer that allows scoping state based on a case path rather than a key path: var body: some ReducerOf<Self> { Scope(state: /State.addItem, action: /Action.addItem) { ItemFormFeature() } Scope( state: /State.duplicateItem, action: /Action.duplicateItem ) { ItemFormFeature() } Scope(state: /State.editItem, action: /Action.editItem) { ItemFormFeature() } }

10:12

And there’s no need to Scope for the alert because alerts don’t have any internal behavior. They are completely ephemeral: you interact with it once and it goes away.

10:23

So now this single Destination reducer encapsulates all the behavior of every single feature that can be navigated to, and it’s modeled in the most concise way possible. It is provable by the compiler that two destinations can never be active at the same time.

11:28

Now that we have just one single piece of optional state representing all of the possible destinations that can be navigated to, we can greatly simplify how we integrate all of the child features together with the parent. Currently we are using the ifLet operator multiple times to accomplish this: .ifLet(\.alert, action: /Action.alert) .ifLet(\.duplicateItem, action: /Action.duplicateItem) { ItemFormFeature() } .ifLet(\.addItem, action: /Action.addItem) { ItemFormFeature() } .ifLet(\.editItem, action: /Action.editItem) { ItemFormFeature() }

11:41

This was really cool to see, but things get even simpler for us now. We can now use ifLet a single time by focusing in on the destination state and actions, and using the Destination reducer: .ifLet(\.destination, action: /Action.destination) { Destination() }

11:48

This is another reason why it’s a good idea to try to package up all the destinations into a single reducer package. We just need to point the ifLet method to the single piece of optionals state and the presentation action, and it takes care of the rest.

12:30

However, this is not quite correct because the ifLet operator requires that the child state being presented is identifiable. The reason for this is we needed some kind of identifiable information for the child so that we can mark its effects as cancellable and then cancel them later.

12:51

Before this destination enum refactor it was true that each child feature’s state was identifiable, such as the alert state and ItemForFeature.State . However, now all of those states are bundled into a destination enum, and it’s that enum that needs to be identifiable: enum State: Equatable, Identifiable { … }

13:10

Unfortunately Swift does not automatically synthesize an Identifiable conformance for enums whose cases all conform to Identifiable . Swift does this for Equatable and Hashable , but not Identifiable .

13:21

So we have to implement this conformance manually, which is luckily straightforward since each case conforms to Identifiable : var id: AnyHashable { switch self { case let .addItem(state): return state.id case let .alert(state): return state.id case let .editItem(state): return state.id case let .duplicateItem(state): return state.id } }

13:49

…but still that’s a bit of a pain. This will make adding new destinations annoying since we have to update this, and are we really going to have to create this conformance every time we need to add navigation to a feature?

14:04

One thing that could help here is macros, which will be coming to Swift soon. Maybe we could mark this enum as DerivingIdentifiable or something and this implementation could be written automatically. @DerivingIdentifiable enum State: Equatable, Identifiable { … } Or maybe: enum State: Equatable, DerivingIdentifiable { … }

14:31

Well, while that would be cool, it’s actually not going to be necessary. There is actually a way to hide away this detail in the ifLet reducer operator, but we are going to look at that later.

14:49

Now that we have fixed the bottom of the reducer Swift can better type check the core of the reducer, and we are now seeing lots of errors we need to fix. But, with each of these fixes we have an opportunity to simplify the logic of the reducer while making it much safer, and the compiler has our back each step of the way to make sure we are doing things right. Sometimes it even feels like we are having a conversation with the compiler.

15:16

For example, currently when the “Add” button is tapped we want to show the item for in a sheet, and so we do that by populating the addItem field with some state: case .addButtonTapped: state.addItem = ItemFormFeature.State( item: Item(name: "", status: .inStock(quantity: 1)) ) return .none

15:27

This works well enough, but it’s also not very safe. What if somehow some other piece of presentation state was populated already, like say the duplicate popover? Then by mutating this state in this fashion we are leaving ourselves open to the possibility of having two destinations be active at the same time.

15:44

So if we wanted to be really defensive in our programming we should probably nil out all of the other destinations before doing this: case .addButtonTapped: state.alert = nil state.duplicateItem = nil state.editItem = nil state.addItem = ItemFormFeature.State( item: Item(name: "", status: .inStock(quantity: 1)) ) return .none

16:07

That gets the job done I guess, but also isn’t very future proof. If later we add a 5th destination to this feature we will need to make sure to come to this part of the code base and add another line to nil out that state. Otherwise we leave ourselves open to invalid states.

16:56

And of course we would never actually write code like this. It’s way too burdensome. Instead we would just hope that it’s never possible to tap the “Add” button while some other destination is activated, but we will never 100% know that is not possible.

17:06

Well, thanks to our new Destination enum, this now is possible. We just need to point the destination state to the .addItem case: case .addButtonTapped: state.destination = .addItem( ItemFormFeature.State( item: Item(name: "", status: .inStock(quantity: 1)) ) ) return .none

17:22

…and we now know that if any other destination was active at this moment then it will instantly be deactivated. It is just not possible for two destinations to be navigated at the same time.

17:35

So already that’s pretty amazing.

17:38

Next we have some code dealing with addItem actions, and actually this is just old code. We no longer need to manually nil out addItem state when a .dismiss action is sent. That’s all handled by the ifLet reducer operator. So, all of this can just be removed: // case .addItem: // return .none

17:49

Next we have the spot where we wanted to be able inspect when the user confirms deletion of the item, and we did that by pattern matching deep into the alert actions: case let .alert(.presented(.confirmDeletion(id))): state.items.remove(id: id) return .none

17:58

This works basically the same for the destination actions, but there’s just one more layer to be dealt with. And pattern matching on this is like having a conversation with the compiler. We first say that want to react to an action being sent into a destination: case let .destination

18:12

And if we type . we will get autocomplete of what is possible next: case let .destination(.<#⎋#>

18:14

We are interested in the case where destination is presented, so we will match on that: case let .destination(.presented

18:20

Then we can type . again to get autocomplete of what’s possible next: case let .destination(.presented(.<#⎋#>

18:22

Now we see the 4 possible destinations that could be presented. The one we are interested in is the alert: case let .destination(.presented(.alert

18:23

One last time we can type . to get autocomplete of what’s next: case let .destination(.presented(.alert(.<#⎋#>

18:25

And here we see all the actions that can happen inside the alert. The one we are interested in is the confirmDeletion , and we will grab the ID of the item that is being deleted: case let .destination( .presented(.alert(.confirmDeletion(id: id))) ): state.items.remove(id: id) return .none

18:32

That’s all it takes.

18:36

Then we have a spot where we were handling the rest of the alert actions, but this too can go away since all of it is bundled into a single destination action: // case .alert: // return .none

18:46

Next we have the cancelAddItemButtonTapped and cancelDuplicateItemButtonTapped actions, which can be updated to nil out the destination state instead of the addItem or duplicateItem domain-specific states: case .cancelAddItemButtonTapped: state.destination = nil return .none case .cancelDuplicateItemButtonTapped: state.destination = nil return .none This is nice. It gives us one single way to clear out all navigation destinations. We don’t even have to think about which particular feature we are dismissing. We can just nil out the destination variable know that all features will be dismissed.

19:28

Next we have the confirmAddItemButtonTapped action, which needs to inspect the current child state being presented in order to append it to the end of the items collection, and then also nil out the state to dismiss. This can be done with a guard case let to focus in on a specific destination case rather than using if let to unwrap some domain-specific state: case .confirmAddItemButtonTapped: defer { state.destination = nil } guard case let .addItem(itemFormState) = state.destination else { XCTFail( "Can't confirm add when destination is not 'addItem'" ) return .none } state.items.append(itemFormState.item) return .none

20:24

The same can be done for the confirmDuplicateItemButtonTapped action: case .confirmDuplicateItemButtonTapped: defer { state.destination = nil } guard case let .duplicateItem(itemFormState) = state.destination else { XCTFail( """ Can't confirm duplicate when destination is not \ 'duplicateItem' """ ) return .none } state.items.append(itemFormState.item) return .none

20:54

The rest of the fixes in the reducer follow basically the same pattern of what we have encountered so far, so we can fix them quickly, except for one particular action. Previously we were listening to when the dismiss action is sent in the edit feature so that we can commit those changes to the item in the root items collection. Now we will instead listen for when the dismiss action is sent for the destination and then check that the destination is of the correct case: case .destination(.dismiss): guard case let .editItem(itemFormState) = state.destination else { return .none } state.items[id: itemFormState.id] = itemFormState.item return .none

22:55

The reducer is now nearly compiling, we just need to add a catch-all for the destination case to handle any actions that are not explicitly handled above: case .destination: return .none

23:13

Now the reducer is compiling, and all remaining errors are down in the view. This means that whatever new tools we may need to create to support destination enums, they aren’t going to be for the reducer layer of the feature. Only the view layer.

23:43

The first error in the view layer is in the ViewState struct where we can no longer easily just grab the editItemID straight from the editItem state. But actually we don’t even need to observe that state anymore because it’s all been hidden away inside the navigationDestination view modifier, so let’s get rid of that: struct ViewState: Equatable { let items: IdentifiedArrayOf<Item> init(state: InventoryFeature.State) { self.items = state.items } }

24:07

Then we’ve got lots of view modifiers that we created that work by handing a store that has been scoped down to just some optional child state and presentation action: .alert( store: self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ) )

24:13

But now we have an extra layer between us and the actual child domain. We have a destination state enum and action enum to get through.

24:31

So, we can no longer provide these simple transformations for state and action. We need to do something a lot more complicated.

24:46

Take the state transformation for example. It takes a piece of parent state, which is InventoryFeature.State , as an argument, and it needs to return an optional piece of child state, which is AlertState : .alert( store: self.store.scope( state: { (inventoryState: InventoryFeature.State) -> AlertState<InventoryFeature.Action.Alert>? in }, … ) )

25:10

In order to accomplish this we can first pluck off the destination field from the parent state, which gives us an optional Destination.State : inventoryState.destination

25:20

We then want to try extracting the alert case from this enum. We can do a guard case let for that: state: { (inventoryState: InventoryFeature.State) -> AlertState<InventoryFeature.Action.Alert>? in guard case let .some(.alert(state)) = inventoryState.destination else { return nil } return state },

25:45

It looks really gross, but it’s technically correct.

25:49

Next we have the action transformation. It’s even more confusing because it goes in the opposite direction. We are handed a child action, in this case a PresentationAction , and we have to somehow bundle it into a parent action, in this case a InventoryFeature.Action : action: { (childAction: PresentationAction<InventoryFeature.Action.Alert>) -> InventoryFeature.Action in }

26:51

Since the child action is a presentation action we can switch on it: switch childAction { case .dismiss: case let .presented(childAction): }

27:15

In the case of it being a dismiss action for the alert we can simply return a dismiss for the destination: case .dismiss: return .destination(.dismiss)

27:23

And in the case of it being a presented action for the alert we can rebundle it into a presentation action for the destination: case let .presented(childAction): return .destination(.presented(.alert(childAction)))

27:56

OK, that compiles, but of course we would never want to write code like this. And we technically need to do this 4 more times to get this code compiling. We can even copy-and-paste what we just did for the alert and make a few small changes to get the other view modifiers working.

28:13

For example, for the popover we just need to change any instances of alert domain to item form domain: .popover( store: self.store.scope( state: { (parentState: InventoryFeature.State) -> ItemFormFeature.State? in guard case let .some(.duplicateItem(state)) = parentState.destination else { return nil } return state }, action: { (childAction: PresentationAction<ItemFormFeature.Action>) -> InventoryFeature.Action in switch childAction { case .dismiss: return .destination(.dismiss) case let .presented(childAction): return .destination( .presented(.duplicateItem(childAction)) ) } } ) )

29:17

Same for the sheet: .sheet( store: self.store.scope( state: { (parentState: InventoryFeature.State) -> ItemFormFeature.State? in guard case let .some(.addItem(state)) = parentState.destination else { return nil } return state }, action: { (childAction: PresentationAction<ItemFormFeature.Action>) -> InventoryFeature.Action in switch childAction { case .dismiss: return .destination(.dismiss) case let .presented(childAction): return .destination( .presented(.addItem(childAction)) ) } } ) )

29:33

And even the navigationDestination : .navigationDestination( store: self.store.scope( state: { (parentState: InventoryFeature.State) -> ItemFormFeature.State? in guard case let .some(.editItem(state)) = parentState.destination else { return nil } return state }, action: { (childAction: PresentationAction<ItemFormFeature.Action>) -> InventoryFeature.Action in switch childAction { case .dismiss: return .destination(.dismiss) case let .presented(childAction): return .destination( .presented(.editItem(childAction)) ) } } ) )

29:46

Now the view is entirely compiling, and the only errors left are the places where we are constructing views with InventoryFeature state.

29:54

For example, down in the preview it is no longer correct to try to construct the InventoryView in a state where editItem is pre-populated. Instead we need to pre-populate the destination field with some state so that we start off already in a drill-down to the edit screen: InventoryView( store: Store( initialState: InventoryFeature.State( destination: .editItem( ItemFormFeature.State( item: Item( id: Item.keyboard.id, name: "Bluetooth Keyboard", color: .red, status: .outOfStock(isOnBackOrder: true) ) ) ), items: [ .headphones, .mouse, .keyboard, .monitor, ] ), reducer: InventoryFeature() ) )

30:19

And we have to do the same in the entry point of the application. Notice that it is now impossible for us to start up the application with two destinations active at the same time. Thanks to us using a destination enum we have no choice but to restrict ourselves to only one single, activate destination, and that’s really great.

31:02

And we officially have everything compiling in the project, and it works exactly as it did before, but now our domain is more concisely modeled. We can know for sure exactly what is presented at any given time by just checking a single piece of state, and we know that it is absolutely impossible for multiple things to be presented at once. A nicer destination enum API Stephen

31:21

However, while things are looking great in the reducer, the view code got a lot messier.

31:26

So, what does it take to improve the API of these tools so that we can embrace enums for our navigation destinations while not making the call sites look this gnarly? It seems that all the work to make this nicer must take place in the view layer because the reducer domain and operators all still look really great.

31:43

For the view layer it seems that there are 2 steps that need to be taken in order to properly scope a store: first you must scope down to the optional destination domain, both the state and actions, and then you have to further scope down to a particular case of the destination enum, again both for the state and actions.

32:01

We didn’t encounter this for the reducer operators because we were able to hide away one of those scoping operations, in particular the one that scopes down to a particular case of the destination enum. We hide that directly inside the Destination reducer. We unfortunately don’t have that ability in the view because we decided to design the corresponding view modifiers in a style that looks similar to the standard SwiftUI view modifiers. That is, we have a separate methods for .alert , .sheet , .popover , and so on.

32:29

So, let’s see what it takes to improve the call site in the view so that it can look as nice as what we do for the reducer.

32:37

What if we could break up all the messiness we have right now into basically 2 steps: first you provide the view modifier a scoped store that focuses on just the optional destination domain, and then you provide the state and action transformations that further single out the case of the destination you are interested in.

32:55

For alerts it might look something like this: .alert( store: self.store.scope( state: \.destination, action: InventoryFeature.Action.destination ), state: /InventoryFeature.Destination.State.alert, action: InventoryFeature.Destination.Action.alert )

34:22

For popovers like this: .popover( store: self.store.scope( state: \.destination, action: InventoryFeature.Action.destination ), state: /InventoryFeature.Destination.State.duplicateItem, action: InventoryFeature.Destination.Action.duplicateItem ) { … }

34:54

And sheets and navigation destination would basically be the same: .sheet( store: self.store.scope( state: \.destination, action: InventoryFeature.Action.destination ), state: /InventoryFeature.Destination.State.addItem, action: InventoryFeature.Destination.Action.addItem ) { … } .navigationDestination( store: self.store.scope( state: \.destination, action: InventoryFeature.Action.destination ), state: /InventoryFeature.Destination.State.editItem, action: InventoryFeature.Destination.Action.editItem ) { … }

35:23

This would clear the fog when it comes to driving navigation from the case of a destination enum, and again unifies all of these seemingly disparate forms of navigation under the same API.

35:40

And if some day Swift natively supports case paths, then there’s a chance we can dramatically simplify these APIs like the following: .alert( store: self.store.scope( state: \.destination, action: \.destination ), state: \.alert, action: { .alert($0) }

36:23

That would be absolutely amazing, but sadly is not a reality today. So let’s undo that real quick.

36:31

So, what does it take to make these theoretical APIs a reality?

36:35

Let’s start with the alert modifier since it’s the simplest. We will extend the View protocol to add a new method: extension View { func alert( ) -> some View { } }

36:51

The method will first take an argument to a store that is focused on some optional destination state and a presentation action: extension View { func alert<DestinationState, DestinationAction>( store: Store< DestinationState?, PresentationAction<DestinationAction> > ) -> some View { } }

37:11

Then we will first take a transformation that can (optionally) extract alert state from a particular case of the destination state: state toAlertState: @escaping (DestinationState) -> AlertState<Action>?,

37:36

As well as a transformation that can bundle up an action back into the DestinationAction : action fromAlertAction: @escaping (Action) -> DestinationAction

37:50

Note that we don’t need the full power of key paths or case paths here. For the purposes of Store scoping we only need a single direction of the transformation, extracting for state and embedding for actions, so simple functions work just fine.

38:05

Luckily in the body of this modifier we don’t need to implement everything from scratch. We can just call out to the existing alert modifier that takes a Store and perform some further scoping: self.alert( store: store.scope( state: <#(DestinationState?) -> ChildState#>, action: <#(ChildAction) -> PresentationAction<DestinationAction>#> ) )

38:27

For the state transformation we need to single out a specific case inside the optional DestinationState , which is exactly what the toAlertState function can do: state: { $0.flatMap(toAlertState) },

38:44

And then the action transformation can basically do what we were doing in an ad hoc fashion over in the inventory feature. We will switch on the incoming action, which is a presentation action for the alert, and bundle it back up into a presentation action for the destination: action: { switch $0 { case .dismiss: return .dismiss case let .presented(action): return .presented(fromAlertAction(action)) } }

39:26

And just like that this method is compiling.

39:30

In fact, this method is basically identical to what we would need to do to support confirmation dialogs too, so let’s do that real quick: extension View { func confirmationDialog< DestinationState, DestinationAction, Action >( store: Store< DestinationState?, PresentationAction<DestinationAction> >, state toAlertState: @escaping (DestinationState) -> ConfirmationDialogState<Action>?, action fromAlertAction: @escaping (Action) -> DestinationAction ) -> some View { self.confirmationDialog( store: store.scope( state: { $0.flatMap(toAlertState) }, action: { switch $0 { case .dismiss: return .dismiss case let .presented(action): return .presented(fromAlertAction(action)) } } ) ) } }

39:53

Next we can attack the sheet modifier. The signature will look quite similar to what we just did for .alert , but it will need an extra argument for the view to be presented in the sheet. extension View { func sheet< DestinationState, DestinationAction, ChildState: Identifiable, ChildAction >( store: Store< DestinationState?, PresentationAction<DestinationAction> >, state toChildState: @escaping (DestinationState) -> ChildState?, action fromChildAction: @escaping (ChildAction) -> DestinationAction, @ViewBuilder child: @escaping (Store<ChildState, ChildAction>) -> some View ) -> some View { } }

40:42

And again, luckily for us, we can just call out to the existing .sheet modifier, and it basically looks the same as what we did for the alert : self.sheet( store: store.scope( state: { $0.flatMap(toChildState) }, action: { switch $0 { case .dismiss: return .dismiss case let .presented(childAction): return .presented(fromChildAction(childAction)) } } ), child: child )

41:14

We can even copy-and-paste this 2 times and make a few small changes in order to support popovers and navigation destinations: extension View { func popover< DestinationState, DestinationAction, ChildState: Identifiable, ChildAction >( store: Store< DestinationState?, PresentationAction<DestinationAction> >, state toChildState: @escaping (DestinationState) -> ChildState?, action fromChildAction: @escaping (ChildAction) -> DestinationAction, @ViewBuilder child: @escaping (Store<ChildState, ChildAction>) -> some View ) -> some View { self.popover( store: store.scope( state: { $0.flatMap(toChildState) }, action: { switch $0 { case .dismiss: return .dismiss case let .presented(childAction): return .presented( fromChildAction(childAction) ) } } ), child: child ) } } extension View { func navigationDestination< DestinationState, DestinationAction, ChildState: Identifiable, ChildAction >( store: Store< DestinationState?, PresentationAction<DestinationAction> >, state toChildState: @escaping (DestinationState) -> ChildState?, action fromChildAction: @escaping (ChildAction) -> DestinationAction, @ViewBuilder child: @escaping (Store<ChildState, ChildAction>) -> some View ) -> some View { self.navigationDestination( store: store.scope( state: { $0.flatMap(toChildState) }, action: { switch $0 { case .dismiss: return .dismiss case let .presented(childAction): return .presented(fromChildAction(childAction)) } } ), destination: child ) } }

41:42

And just like that everything in the project is compiling. Even the theoretical syntax we outlined over in the inventory feature.

41:59

The couple dozen lines at the bottom of the inventory view are starting to look really amazing. We get to use basically the same style of API in 4 different ways and we are able to drive navigation for an alert, sheet, popover and drill-down off a single destination enum. And we now have compile-time proof that only one destination can be active at a time. Testing correctness

42:22

Ok, things are looking really great in both the reducer layer and the view layer. We now have the tools to drive all types of navigation from a single enum, and it is modeled in the most concise way possible. We have just one single piece of state to check if we want to see if something is presented. Brandon

42:37

This is all great, but before going too much further we should see how all of these changes have affected testing. I would hope there isn’t much that needs to be changed because we have only slightly tweaked the API for how things are structured, but let’s be sure.

42:54

When I try building for tests we will see that there are a lot of compilation errors. They are all quite easy to fix. For example, the first is in the testDelete method where we test the user flow of deleting an item. After emulating the user tapping on the delete button we want to mutate the state to assert that the alert was shown: await store.send(.deleteButtonTapped(id: item.id)) { $0.alert = .delete(item: item) }

43:29

This is no longer correct because we don’t have a separate field for alert state. Instead we have a single field for destination state and it has a case for the .alert : await store.send(.deleteButtonTapped(id: item.id)) { $0.destination = .alert(.delete(item: item)) }

43:44

Next there’s an error where we are trying to send an action from within the presented alert: await store.send( .alert(.presented(.confirmDeletion(id: item.id))) ) { … }

43:49

Things get a little more nested and flipped with our new destination enum style of navigation. First we want to send a destination action since that is where the alert lives: await store.send(.destination

44:00

And further we want to send an action when the destination is presented: await store.send(.destination(.presented

44:02

And the destination we are interested in is the alert destination: await store.send(.destination(.presented(.alert

44:04

And finally we want to send the confirmDeletion action from within the alert: await store.send( .destination( .presented(.alert(.confirmDeletion(id: item.id))) ) )

44:09

That’s now compiling, but down below we have a compiler error when trying to nil out the alert state in order to assert that the alert goes away after making a choice: $0.alert = nil The fix for this is to instead nil out the destination state to represent no longer being navigated anywhere: $0.destination = nil

44:12

And just like that testDelete is now compiling.

44:17

Let’s quickly fix the rest of the test suite. Almost everything follows the pattern we just saw for the delete test.

44:25

For example, when emulating tapping on the duplicate button we will assert that the destination state flipped to the duplicateItem case: await store.send(.duplicateButtonTapped(id: item.id)) { $0.destination = .duplicateItem(…) }

44:51

And when sending an action in the duplicate domain we will have to go through the destination case: await store.send( .destination( .presented( .duplicateItem( .set(\.$item.name, "Bluetooth Headphones") ) ) ) ) {

45:39

Next we have an error when making the state assertion because currently we are trying to say that the duplicate item’s name has changed: $0.duplicateItem?.item.name = "Bluetooth Headphones"

45:48

But the duplicate item state is now housed in a case of the destination enum. We need to be able to unwrap that case and modify the data inside.

46:02

This takes a little more work, but luckily our case paths library comes with the tool to make this quite straightforward. The tool is called XCTModify : XCTModify( <#&Root#>, case: <#CasePath<Root, Case>#>, <#(inout Case) throws -> Result#> )

46:12

It’s a companion to the XCTUnwrap helper that ships with XCTest, but in addition to safely unwrapping a value, in particular a case of an enum, it will also allow you to apply an in-place mutation to the value held in the case.

46:39

So, the value we want to unwrap is the state’s destination: XCTModify( &$0.destination, case: <#CasePath<Root, Case>#>, <#(inout Case) throws -> Result#> )

46:47

And the case inside the destination enum we want to mutate is the duplicateItem case, which can be specified with a case path: XCTModify( &$0.destination, case: /InventoryFeature.Destination.State.duplicateItem ) { … }

46:55

And the mutation we want to perform will change the item’s name to “Bluetooth Headphones”: XCTModify( &$0.destination, case: /InventoryFeature.Destination.State.duplicateItem ) { $0.item.name = "Bluetooth Headphones" }

47:24

While XCTModify is handy, it’s worth mentioning that there are features coming to Swift that will make this in-place mutation possible: guard case let .duplicateItem(&state) = $0.destination else { return XCTFail() } state.item.name = "Bluetooth Headphones"

48:13

If the destination is nil or is not of the duplicateItem case it will fail the test, keeping you in check to make sure you are handling things correctly.

48:19

The final thing to update in this test is when the duplication is confirmed we want to nil out the destination instead of the duplicateItem state: $0.destination = nil

48:23

And now this test is compiling, and just to make sure it passes let’s comment out all the tests below it.

48:40

And running this one single test we see it passes.

48:57

The next test is testAddItem and it is quite similar to testDuplicateItem . We just need to update some actions to deal with destination actions, and we need to use XCTModify to mutate the state inside the destination enum state.

50:51

The next test is testAddItem_Timer , which shows that we can start a timer in a child feature and then dismiss the child feature and the child effects will be properly torn down. This test can be updated just like the other ones, so we will do that really quickly.

52:46

Next we have testAddItem_Timer_Dismissal , which shows that if we start the timer in the child feature, then after a few ticks of the timer the child will automatically dismiss itself and tear down all effects. This too can be updated like the other tests, but it is quite a bit more cumbersome since we are asserting on so many child actions and needing to use XCTModify many times.

54:23

This is getting to be quite a slog to exhaustively test the integration of many features, especially when those features are driven off of enum state. This is what motivated us to explore non-exhaustive tests in previous episodes, where we get to assert on the high-level behavior of the feature without having to literally assert on everything .

55:24

For example, the next test shows that we can prove that the child does dismiss itself after some time, but we don’t have to assert on everything that happens in the process of that happening: await store.send(.addButtonTapped) await store.send( .destination( .presented(.addItem(.set(\.$isTimerOn, true))) ) ) await store.receive(.destination(.dismiss)) { $0.destination = nil }

56:51

This cuts through all the noise and doesn’t even need to use the XCTModify helper. We just get to assert that if we start the timer, then some time later the child feature dismisses itself.

57:21

The final 2 tests are for the edit feature flows, and they can be updated much like all the other tests we have handled so far.

59:16

The entire test suite is now compiling, so let’s run it:

59:22

Unfortunately there are a few test failures, and they are all in testDelete , which exercises the user flow of the user deleting an item from the inventory. It is failing with two errors: testDelete(): An effect returned for this action is still running. It must complete before the end of the test. … Failed: testDelete(): A state change does not match expectation: … InventoryFeature.State( − destination: nil, + destination: .alert( + AlertState( + title: #"Delete "Headphones""#, + actions: [ + [0]: ButtonState( + role: .destructive, + action: .send( + .confirmDeletion( + id: UUID( + D74D353D-C6E1-4FAD-BDC2-3C6E79F53485 + ) + ), + animation: Animation.easeInOut + ), + label: "Delete" + ) + ], + message: """ + Are you sure you want to delete this item? + """ + ) + ), items: [] ) (Expected: −, Actual: +)

59:33

The first error tells us that an effect is still in flight, and that’s strange because the process of deleting an inventory item doesn’t involve effects at all. The second error says that our state changes do not match what happened in reality. In particular, the destination was not cleared out for some reason even though an alert action was sent.

1:00:17

We already have special logic in the ifLet reducer specifically to handle “ephemeral” state, of which alerts and confirmation dialogs are examples, but it seems that logic has now broken. If we look at the source of ifLet we will see three places we check for _EphemeralState .

1:00:41

First we check if the child state is ephemeral so that we can immediately nil it out when we process a child action: if ChildState.self is _EphemeralState.Type { state[keyPath: stateKeyPath] = nil } Then we check if ChildState is ephemeral so that if it is not we will cancel any child effects because the child feature is being dismissed: if !(ChildState.self is _EphemeralState.Type), let childBefore, childBefore.id != childAfter?.id { … } Then we do something similar in order to figure out the first time the child is being presented so that we can attach the long living effect that allows us to dismiss from the child: if !(ChildState.self is _EphemeralState.Type), let childAfter, childBefore?.id != childAfter.id { … }

1:01:14

And for some reason these checks are no longer correct. Let’s put a breakpoint before the first of these to see what is going on.

1:01:25

And we’ll run the testDelete test again.

1:01:31

OK, now we are on the breakpoint, and if we print the type of ChildState we will see: (lldb) po ChildState.self Inventory.InventoryFeature.Destination.State OK, well I guess that makes sense. The “child” state we are focusing in on for the purpose of the ifLet operator is the full Destination.State enum, which definitely is not “ephemeral”. Only the alert state held in the alert case of the destination enum is ephemeral.

1:01:56

Seems like we have to beef up this logic quite a bit. We can’t simply check if the ChildState is ephemeral, but rather we need to check if the data inside the case of the destination enum is ephemeral.

1:02:07

Essentially we want to implement a function like this: private func isEphemeral<State>(_ state: State) -> Bool { }

1:02:21

It should be capable of taking a value of any type, and we determine if it is directly an ephemeral type, or an enum whose case holds onto an ephemeral type. Sounds wild, but what does it take to implement this?

1:02:43

Well, we can start with the simple case, which checks if the type is directly ephemeral: private func isEphemeral<State>(_ state: State) -> Bool { if State.self is _EphemeralState.Type { return true } }

1:02:54

Next is the hard part. We need to detect if the State type is an enum, and if it is extract out the type of associated data it holds. This is surprisingly difficult to do in Swift, and requires us to dip our toes into the vast ocean of runtime metadata.

1:03:12

We aren’t going to dive deep into this topic because it’s far too expansive, but we will borrow some tools that ship with our case paths library . That library makes extensive use of Swift’s runtime metadata capabilities in order to dynamically derive case paths for each case of an enum. For the most part those tools are private to the library, but we have made a few of them somewhat “public” so that others can use, but it is hidden behind @_spi in order to make it clear that these tools could change at anytime in the future.

1:03:47

To get access to the tools you must import CasePaths in a special way: @_spi(Reflection) import CasePaths

1:03:56

This exposes certain APIs in the library that are technically public, but only made accessible if you import in this strange way.

1:04:20

With that done we have access to a type called EnumMetadata that can help us dive into the internals of an enum. It’s initializer is failable because you might try using it on a non-enum: } else if let metadata = EnumMetadata(State.self) {

1:04:34

If that succeeds then we have an enum on our hands, and we can further get access to the type of the associated value by using the associatedValueType method: metadata.associatedValueType(forTag: metadata.tag(of: state))

1:05:05

That right there returns a type, which we can finally check if it is ephemeral: return metadata.associatedValueType( forTag: metadata.tag(of: state) ) is _EphemeralState.Type And finally we can add an else branch to return false if none of the above succeeds: @_spi(Reflection) import CasePaths private func isEphemeral<State>(_ state: State) -> Bool { if State.self is _EphemeralState.Type { return true } else if let metadata = EnumMetadata(type(of: state)) { return metadata.associatedValueType( forTag: metadata.tag(of: state) ) is _EphemeralState.Type } else { return false } }

1:05:17

We can now replace our direct checks against the ChildState type with this function.

1:05:26

For example, when checking if we should nil out the child state after receiving a child action, we can simply do: if isEphemeral(childState) { state[keyPath: stateKeyPath] = nil }

1:06:04

And then when checking if the child feature first appears, we will make sure the child state was not ephemeral before starting the effect that allows the child to dismiss itself. Ephemeral state is always dismissed when processing actions. if let childAfter, !isEphemeral(childAfter), childBefore?.id != childAfter.id { … }

1:06:29

And similarly for checking when a child feature was dismissed so that we can cancel effects, we will make sure that the child state was not ephemeral state since in that case there is nothing to cancel: if let childBefore, !isEphemeral(childBefore), childBefore.id != childAfter?.id { … }

1:07:06

Now when we run tests everything passes. Next time: Efficient state management

1:07:21

So this is all looking really great. We now have better tools for modeling our domains more concisely.

1:07:27

Previously when we wanted to be able to navigate to a new child feature from an existing feature we would just throw an optional into our domain. However, with each additional optional you add to a domain you double the number of invalid states in your feature. For example, we had 4 options representing 4 different destinations, which means there were 16 different states our feature could be in, only 5 of which were valid.

1:07:48

Now we can embrace enums when modeling the destinations for our features, which gives us just one single place to describe all the different places a feature can navigate to. That gives us us more correctness in our code because the compiler is proving for us that only one destination can be activated at a time. Stephen

1:08:05

Next let’s address a problem that gets nearly everyone eventually when using our library, especially when composing lots of features together. Because modeling navigation in state requires us to nest child state in parent state, any sufficiently complex application eventually has a very large root app state that could potentially have hundreds of fields. This also means that the amount of memory you are storing on the stack will increase as you integrate more child features together.

1:08:30

This may not matter for awhile, but eventually your state may get too big or you may have too many frames on the stack, and you could accidentally overflow the stack. That crashes the application, and so that of course isn’t great.

1:08:44

Now it’s worth noting a couple of caveats here. Not all of your application’s state is necessary stored on the stack. Arrays, dictionaries, sets, and even most strings are all stored on the heap, and so it doesn’t matter if you have a 100,000 element array in your state, that makes no difference for the stack.

1:09:00

Also we made great strides towards reducing the number of stack frames that are incurred when combining lots of features together, all thanks to the Reducer protocol. In release mode most of those compositions get inlined away and you are left with very few actual stack frames.

1:09:15

But still, people do run into this limitation, and it’s a real bummer.

1:09:20

However, by far the most common reason for multiple features to be integrated together is because of navigation. You plug “FeatureB” into “FeatureA” when you need to navigate from A to B. As you do this more and more your state becomes bigger and bigger.

1:09:33

And now we are going to be giving everyone more tools to build up state like this for navigation, and so it may start happening a lot more. Perhaps we can directly bake into the tools a more efficient way of storing state so that it plays more nicely with deeply nested, composed features…next time! References Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0229-composable-navigation-pt8 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 .