Video #223: Composable Navigation: Alerts & Dialogs
Episode: Video #223 Date: Feb 20, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep223-composable-navigation-alerts-dialogs

Description
Let’s dip our toes into the new composable navigation tools by improving how alerts and confirmation dialogs can used in the library. We will create a new reducer operator that more correctly handles the logic and hides unnecessary details.
Video
Cloudflare Stream video ID: 8efc260e438a502c47d0a1365d546dff Local file: video_223_composable-navigation-alerts-dialogs.mp4 *(download with --video 223)*
References
- Discussions
- Identified Collections
- SwiftUI Navigation
- our new dependency management library
- Single entry point systems
- dependencies library
- Composable navigation beta GitHub discussion
- 0223-composable-navigation-pt2
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
So, we are seeing that if you want integrated features in vanilla SwiftUI in order to reap all the benefits that brings, you still have a number of steps to take to get it right, but in some sense there is even less help from the compiler and runtime to make sure you did everything correctly.
— 0:21
We think everything we have accomplished so far is pretty cool and even impressive, but its real purpose is to dip our toes into composing features together and communicating between features, because that concept is central to the Composable Architecture, and even more so with navigation.
— 0:41
By composing many features together, including all the destinations one can navigate to, we can have a very simple representation of something that is actually quite complex. No matter how many different places you can navigate to from a screen, and no matter how many layers deep you are in a navigation stack, you will have the ability to inspect what is happening in every layer and introduce new logic to integrate everything together. It’s honestly amazing to see. Stephen
— 1:06
But building those tools takes time, and to get the first hint at what those tools will look like eventually we will turn to one of the simplest forms of navigation: alerts. Alerts have the notion of presenting and dismissing, but they don’t manage logic and behavior on the inside. They simply show some buttons, and the user taps one of them to dismiss and optionally kick off an action.
— 1:29
Let’s start by showing how we can add an alert to this application using the tools that the Composable Architecture already comes with, and in fact these tools have been in the library since basically the beginning. And then we will see how we can greatly improve the tools, and that will set the stage for more complicated forms of navigation, such as sheets, popovers ,and even drill-downs. Alerts today
— 1:48
To get alerts into our application we are going to start showing a list of inventory items in the inventory tab, and have a button in the row that allows deleting the item, but first an alert will be shown for the user to confirm deletion.
— 2:11
In order to get a list of inventory items showing in the UI we need some models to play around with, and I will grab those straight from the inventory app we previously built when dealing with just plain vanilla SwiftUI. import Foundation import SwiftUI public struct Item: Equatable, Identifiable { public let id: UUID public var name: String public var color: Color? public var status: Status public init( id: UUID? = nil, name: String, color: Color? = nil, status: Status ) { self.id = id ?? UUID() self.name = name self.color = color self.status = status } public enum Status: Equatable { case inStock(quantity: Int) case outOfStock(isOnBackOrder: Bool) public var isInStock: Bool { guard case .inStock = self else { return false } return true } } public func duplicate() -> Self { Self( name: self.name, color: self.color, status: self.status ) } public struct Color: Equatable, Hashable, Identifiable { public var name: String public var red: CGFloat = 0 public var green: CGFloat = 0 public var blue: CGFloat = 0 public init( name: String, red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0 ) { self.name = name self.red = red self.green = green self.blue = blue } public var id: String { self.name } public static var defaults: [Self] = [ .red, .green, .blue, .black, .yellow, .white, ] public static let red = Self(name: "Red", red: 1) public static let green = Self(name: "Green", green: 1) public static let blue = Self(name: "Blue", blue: 1) public static let black = Self(name: "Black") public static let yellow = Self(name: "Yellow", red: 1, green: 1) public static let white = Self(name: "White", red: 1, green: 1, blue: 1) public var swiftUIColor: SwiftUI.Color { SwiftUI.Color( red: self.red, green: self.green, blue: self.blue ) } } } extension Item { static let headphones = Self( name: "Headphones", color: .blue, status: .inStock(quantity: 20) ) static let mouse = Self( name: "Mouse", color: .green, status: .inStock(quantity: 10) ) static let keyboard = Self( name: "Keyboard", color: .yellow, status: .outOfStock(isOnBackOrder: false) ) static let monitor = Self( name: "Monitor", color: .red, status: .outOfStock(isOnBackOrder: true) ) }
— 2:27
There’s only one somewhat interesting thing about these models, and that is how we have handled the status of an item. An item can be either in stock or out of stock. When it is in stock there is a quantity associated, and when it is out of stock it can either be on back order or not.
— 2:43
We decided to model this as an enum because it seems like there are two mutually exclusive decisions: you can either be in stock or out of stock.
— 2:51
However, there are other ways you can model this data, such as with optionals: var quantity: Int? var isOnBackOrder: Bool?
— 3:03
But this allows for a lot of non-sensical combinations of data, such as both values being nil or non- nil . So, it is much better to model your domains as concisely as possible, but sometimes you will but heads with SwiftUI when you do this, and we will encounter that in a moment.
— 3:20
Now that we have some models to play around with, we can add a collection of items to our InventoryFeature state. But, we won’t use a plain array, because as we’ve discussed a number of times on Point-Free, using plain arrays to model UI lists of data can lead to corrupting data and even crashes. This is due to using positional indices to read and modify elements from the collection.
— 3:49
So, instead, we will make use of our Identified Collections library, which provides a data type called IdentifiedArray that allows you to read and modify elements via their stable IDs, rather than positional indices: struct InventoryFeature: ReducerProtocol { struct State: Equatable { var items: IdentifiedArrayOf<Item> = [] } … }
— 4:10
Now that we have some real data in our feature’s state we can start observing the state and displaying it in the UI.
— 4:16
The first thing we need to do it construct a view store so that we can observe state changes and have the ability to send actions into the system: struct InventoryView: View { let store: StoreOf<InventoryFeature> var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in … } } }
— 4:36
Now, before adding view hierarchy inside here we want to learn from a lesson discussed last episode, in that typically you do not want to observe all of state when constructing view stores. This is less true at leaf nodes of the application, but becomes critical in intermediate and root-level views.
— 4:53
The only state we need to show the list of inventory items is the items array, so let’s just observe that: WithViewStore( self.store, observe: \.items ) { viewStore in
— 5:02
Then, in the future, as the InventoryFeature ’s state continues to grow and grow, we will not unnecessarily incur extra view recompilations.
— 5:11
Now inside this WithViewStore we have full access to the items, and so we can construct a List with a ForEach inside, and then a bunch of view hierarchy inside there to show all the details of the items.
— 5:21
We aren’t going to show how to do this from scratch because it isn’t really important for our end goal of understanding navigation, so we are just going to paste in a bunch of it: WithViewStore( self.store, observe: \.items ) { viewStore in List { ForEach(viewStore.state) { item in HStack { VStack(alignment: .leading) { Text(item.name) switch item.status { case let .inStock(quantity): Text("In stock: \(quantity)") case let .outOfStock(isOnBackOrder): Text( "Out of stock\ \(isOnBackOrder ? ": on back order" : "")" ) } } Spacer() if let color = item.color { Rectangle() .frame(width: 30, height: 30) .foregroundColor(color.swiftUIColor) .border(Color.black, width: 1) } Button { … } label: { Image(systemName: "trash.fill") } .padding(.leading) } .buttonStyle(.plain) .foregroundColor( item.status.isInStock ? nil : Color.gray ) } } }
— 5:29
It’s pretty standard stuff. Just some UI for the title, status, color, and a trailing trash icon that we want to use to delete the item.
— 5:41
And before getting to implementing the delete logic, let’s make sure the UI looks OK by getting a preview into place. We even have some mock items we can use to populate the list: struct Inventory_Previews: PreviewProvider { static var previews: some View { NavigationStack { InventoryView( store: Store( initialState: InventoryFeature.State( items: [ .headphones, .mouse, .keyboard, .monitor, ] ), reducer: InventoryFeature() ) ) } } }
— 5:53
OK, this looks pretty good so far.
— 5:55
Now let’s implement the behavior where tapping the delete button should show an alert to confirm that the user truly does want to delete that item. We can begin by adding an action to the feature’s Action enum, and it will take the ID of the item that the user wants to delete: struct InventoryFeature: ReducerProtocol { … enum Action: Equatable { case deleteButtonTapped(id: Item.ID) } … }
— 6:14
Then we can send that action from the view: Button { viewStore.send(.deleteButtonTapped(id: item.id)) } label: { Image(systemName: "trash.fill") }
— 6:28
And we can handle the action in the reducer: func reduce( into state: inout State, action: Action ) -> Effect<Action> { switch action { case let .deleteButtonTapped(id: id): // Show alert return .none } }
— 6:38
The question is: how do we show an alert?
— 6:41
Well, the Composable Architecture comes with a data type that is perfect for modeling alerts as a simple value type, which makes it great for storing in a feature’s state, and it’s even testable.
— 6:50
And actually, to be precise, this data type is vended by our SwiftUI Navigation library which the Composable Architecture depends on, and so in reality this type can even be used to help you write testable, concise alerts in vanilla SwiftUI.
— 7:03
We can start by holding onto an optional piece of what is known as AlertState : struct InventoryFeature: ReducerProtocol { struct State: Equatable { var alert: AlertState<…>? … } … }
— 7:13
AlertState is a simple struct data type that is generic over the type of actions that can be sent from the various buttons in the alert. Technically we can just use our feature’s Action enum: var alert: AlertState<Action>?
— 7:34
And then we could expand the cases in the Action enum to account for what we can do in the alert, in particular confirming deletion: enum Action { case confirmDeletion(id: Item.ID) case deleteButtonTapped(id: Item.ID) }
— 7:41
But, even better, we can carve out a little enum specifically for just the actions that the alert can perform, so as to not conflate them with the other user actions: var alert: AlertState<Action.Alert>? … enum Action: Equatable { case alert(Alert) case deleteButtonTapped(id: Item.ID) enum Alert: Equatable { case confirmDeletion(id: Item.ID) } }
— 8:06
This will also make it impossible for an alert button to send an action that it isn’t intended to be able to send.
— 8:12
We can handle this new action in the reducer by implementing the logic to remove the item by its ID, which thanks to IdentifiedArray is straightforward and performant: case let .alert(.confirmDeletion(id: id)): state.items.remove(id: id) return .none
— 8:41
In particular, there’s no need to linearly scan the collection in order to find the item we want to remove.
— 8:46
Now we can construct the AlertState when the delete button is tapped by providing 3 pieces of information: the title, the actions and the message: case let .deleteButtonTapped(id: id): state.alert = AlertState { } actions: { } message: { }
— 9:07
The title is specified by the first argument, and we provide this information by constructing TextState , which is a value-type friendly and test-friendly way of specifying SwiftUI Text views.
— 9:19
It’d be nice if we could provide some context for the user by showing the item’s name that is to be deleted, so we will need to unwrap that first: case let .deleteButtonTapped(id: id): guard let item = state.items[id: id] else { return .none } state.alert = AlertState { TextState(#"Delete "\#(item.name)""#) } actions: { } message: { }
— 9:51
Next we can provide a message to ask them to confirm they truly do want to delete this item: state.alert = AlertState { TextState(#"Delete "\#(item.name)""#) } actions: { } message: { TextState("Are you sure you want to delete this item?") }
— 10:12
And finally we can provide the actions by constructing ButtonState values, which are like test friendly versions of the SwiftUI Button view: state.alert = AlertState { TextState(#"Delete "\#(item.name)""#) } actions: { ButtonState( role: .destructive, action: .confirmDeletion(id: item.id) ) { TextState("Delete") } } message: { TextState("Are you sure you want to delete this item?") }
— 10:43
This completes the work for the feature as far as the domain is concerned. Next we need to implement the view portion of the feature. In particular, we want to show and hide an alert based on this new alert state that we have modeled in our domain.
— 10:55
To do this there is a special alert view modifier that comes with the Composable Architecture that is specifically tuned for dealing with optional AlertState to drive the presentation and dismissal of alerts: .alert( <#Store<AlertState<Action>?, Action>#>, dismiss: <#Action#> )
— 11:06
You just need to hand it a store that speaks AlertState and this is another job for the scope operator on stores: .alert( self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ), dismiss: <#Action#> )
— 11:44
And there’s a second argument called dismiss . This is here because it technically is possible to create an alert button that doesn’t send an action at all. In fact, SwiftUI can even implicitly add buttons into the alert for you if you forget to do something, such as provide a cancel button. It is also even possible for the user to hit the escape key on a connected keyboard to dismiss an alert.
— 12:04
So, for this reason, we need a kind of catchall action to send because something has to be sent to the store in order to clear out alert state in the system. This means we need to add an action to our alert action enum for dismissal: enum Alert: Equatable { case confirmDeletion(id: Item.ID) case dismiss }
— 12:23
And then we need to handle that action in the reducer: case .alert(.dismiss): state.alert = nil return .none
— 12:33
And finally we can finish providing the arguments for the alert view modifier: .alert( self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ), dismiss: .dismiss )
— 12:40
Now things are compiling, and if we it in the simulator we will see it works. We can tap the trash icon on a row, we get an alert, and confirming deletion causes that row to be removed.
— 12:53
Notice that a “Cancel” button appears in the alert even though we did not specify one in our AlertState . This is what we alluded to a moment ago. SwiftUI will make sure that the alert that shows is reasonable, and so if you don’t provide a cancel button it will stick one in for you. And that is further why we needed that dedicated .dismiss action, so that it could be sent when the “Cancel” button is tapped.
— 13:16
One strange thing is that the row is removed without any animation. We can support animation by tweaking the ButtonState to send its action with an animation: ButtonState( role: .destructive, action: .send( .confirmDeletion(id: item.id), animation: .default ) ) { TextState("Delete") }
— 13:45
And now things animate. The problem
— 13:49
So, things do work now, but we would like to level with all of our viewers for a moment and say that we do not think this is a fantastic experience for adding alerts to features with the Composable Architecture. It’s weird that we need to include this extra .dismiss action just to handle cancelling the alert. And if we had forgotten to clear out the alert state when that action is sent then we could leave our application in an inconsistent state in which the alert is not shown but the state says it should be shown. Brandon
— 14:15
And things are actually a little stranger than first meets the eye.
— 14:18
To see this let’s use a little debug helper on our reducer that prints out state changes on our feature whenever an action is sent into the system. I will make use of this at the root of the application: WindowGroup { ContentView( store: Store( initialState: AppFeature.State(), reducer: AppFeature() ._printChanges() ) ) }
— 14:35
Now when we run the app and we cause an action to be sent, such as tapping the “Go to inventory tab” button, we get logs in the console that show us exactly what actions were sent into the system and how they caused state to change: received action: AppFeature.Action.firstTab(.goToInventoryButtonTapped) (No state changes) received action: AppFeature.Action.firstTab( .delegate(.switchToInventoryTab) ) AppFeature.State( firstTab: FirstTabFeature.State(), inventory: InventoryFeature.State(…), - selectedTab: .one, + selectedTab: .inventory, thirdTab: ThirdTabFeature.State() )
— 14:52
This shows that indeed the .goToInventoryButtonTapped action was sent, which then caused the delegate action to be sent, and that made the selectedTab to switch to .inventory . Even cooler, because nothing else changed in the other fields, they were automatically collapsed so that they don’t pollute the logs.
— 15:26
So that’s cool, but also we don’t have any items in the inventory so this screen is just blank. And we don’t have the UI in place for adding new items. Well, luckily we can just stub some items right in the entry point: WindowGroup { ContentView( store: Store( initialState: AppFeature.State( inventory: InventoryFeature.State( items: [ .monitor, .mouse, .keyboard, .headphones ] ) ), reducer: AppFeature() ._printChanges() ) ) }
— 16:04
Now we have some items showing. Let’s tap the delete icon to bring up the alert, and then clear the logs:
— 16:13
Now when we confirm the deletion we will see the following logs: received action: AppFeature.Action.inventory( .alert( .confirmDeletion( id: UUID(9F1CCF86-388E-4848-A550-8A2858DA0848) ) ) ) AppFeature.State( firstTab: FirstTabFeature.State(), inventory: InventoryFeature.State( alert: AlertState(…), items: [ … (2 unchanged), - [2]: Item( - id: UUID(9F1CCF86-388E-4848-A550-8A2858DA0848), - name: "Keyboard", - color: Item.Color( - name: "Yellow", - red: 1.0, - green: 1.0, - blue: 0.0 - ), - status: .outOfStock(isOnBackOrder: false) - ), [2]: Item(…) ] ), selectedTab: .inventory, thirdTab: ThirdTabFeature.State() ) received action: AppFeature.Action.inventory( .alert(.dismiss) ) AppFeature.State( firstTab: FirstTabFeature.State(), inventory: InventoryFeature.State( - alert: AlertState( - title: "Delete Keyboard", - actions: [ - [0]: ButtonState( - role: .destructive, - action: .send( - .confirmDeletion( - id: UUID( - 9F1CCF86-388E-4848-A550-8A2858DA0848 - ) - ), - animation: Animation.easeInOut - ), - label: "Delete" - ) - ], - message: - "Are you sure you want to delete this item?" - ), + alert: nil, items: […] ), selectedTab: .inventory, thirdTab: ThirdTabFeature.State() )
— 16:29
This shows that when the deletion is confirmed a two-step process occurs: an alert action is sent, causing the item to be removed from the array, and that’s great, but also the alert state did not clear out. Instead, secretly, a dismiss action is also sent, and that actually clears out the alert state.
— 17:21
This two-step action process for tapping an alert button is strange. For one thing, since it’s always sent, it means there’s no way to determine if the user has simply cancelled the alert without performing an action or if they actually performed an action.
— 17:56
And for another thing, because it’s a two-step process that happens only in the view layer, we have to remember to reproduce those steps while writing tests. The library can’t do anything to help us out.
— 18:09
Let’s see this first hand by writing a quick test for the feature we just added. We can get a quick stub into place in InventoryTests.swift: func testDelete() async { }
— 18:17
And we can construct a TestStore that starts out with a single item in the inventory: let item = Item.headphones let store = TestStore( initialState: InventoryFeature.State(items: [item]), reducer: InventoryFeature() )
— 18:38
And then we can send actions into the store to simulate the user doing something, such as tapping on the delete button for that item: await store.send(.deleteButtonTapped(id: item.id)) { }
— 18:46
Then, inside this trailing closure, we need to assert on how state changed when the action is sent. We know the alert state will be populated: await store.send(.deleteButtonTapped(id: item.id)) { $0.alert = <#???#> }
— 19:02
…however, to do that we have to construct that big piece of gnarly AlertState , which would be a pain. Instead we can extract out that value into a static helper: extension AlertState where Action == InventoryFeature.Action.Alert { static func delete(item: Item) -> Self { return AlertState { TextState(#"Delete "\#(item.name)""#) } actions: { ButtonState( role: .destructive, action: .send( .confirmDeletion(id: item.id), animation: .default ) ) { TextState("Delete") } } message: { TextState( "Are you sure you want to delete this item?" ) } } }
— 19:46
Which can be used in the reducer like this: case let .deleteButtonTapped(id: id): guard let item = state.items[id: id] else { return .none } state.alert = .delete(item: item) return .none
— 19:53
And similarly can be used in tests: await store.send(.deleteButtonTapped(id: item.id)) { $0.alert = .delete(item: item) }
— 20:07
And now tests build and they even pass. So we are proving that when the delete button is tapped that indeed an alert is shown. And we are even asserting on all aspects of the alert, including its title, message, actions and even the animation used when sending the action.
— 20:34
Further, let’s simulate the user confirming to delete the item, which should make the items array become empty: await store.send(.alert(.confirmDeletion(id: item.id))) { $0.items = [] }
— 20:56
And this too passes, which is great!
— 21:00
Technically we can now walk away and feel that we have a test that actually captures what really happens in reality in the app, but that’s not really true. It’s never possible to send the confirmDeletion action without it immediately being followed up by the dismiss action. That means if we were to leave the test as-is, we’ve only captured something that can’t really happen in the app, which significantly weakens the test.
— 21:34
We don’t even have any test coverage on the fact that eventually the alert state gets nil ’d out. It is on us to remember to do this by sending another action: await store.send(.alert(.dismiss)) { $0.alert = nil }
— 21:53
This test passes, and we are now getting coverage on what would actually happen in the real app, it’s just a little weird that we have to manually emulate it rather than it just coming for free. If we forget to send the dismiss action then we could have a passing test that doesn’t actually capture reality. Reducer.alert Stephen
— 22:17
And the main reason we have this mismatch between reality and our test is because we don’t have a reducer operator that bakes in the behavior for alerts. We have the special .alert view modifier which takes a store, and under the hood it does the work to wire up all the alert details so that you don’t have to think much about it.
— 22:34
But we need that equivalent thing over in the reducer world. We should be able to invoke an alert method on a reducer so that we can enhance it with the functionality of handling an alert, just as we do over in the view, and this reducer operator would be responsible for making sure that what happens in the reducer properly reflects reality. In particular, we won’t need to maintain and send this extra .dismiss action.
— 22:58
So, let’s try that.
— 23:03
First, let’s take a quick look at where this pattern of view modifier and reducer operator duality is already at play in the library. The Composable Architecture ships a tool called ifLet defined on reducers, and it handles the work of managing optional state in a feature.
— 23:22
To use it you specify a key path and case path that identifies the optional child state you want to operate on: public func ifLet< WrappedState, WrappedAction, Wrapped: Reducer >( _ toWrappedState: WritableKeyPath< State, WrappedState? >, action toWrappedAction: CasePath< Action, WrappedAction >, @ReducerBuilder< WrappedState, WrappedAction > then wrapped: () -> Wrapped, … )
— 23:28
…and the third argument is the reducer you want to run on the optional when the child state is non- nil .
— 23:34
If you provide these 3 arguments it will carefully unwrap the child state when possible, and run the child reducer on that state.
— 23:41
Then there’s a corresponding view that ships in the library called IfLetStore , which handles the view logic for an optional child feature. It takes care of the tough work of deriving a store that deals with non-optional state from a store that has optional state, and does so safely and efficiently.
— 23:58
This pairing of a reducer operator with a view modifier is common in the library, for example there is a forEach operator on reducers and a ForEachStore view that services a similar purpose as ifLet , but for collections of data. And this pattern will become a lot more common as we build out more navigation tools.
— 24:21
So, let’s see what it takes to repeat this program for alerts.
— 24:24
We’ll create a new file, Navigation.swift, which will hold all the new navigation tools we are going to create.
— 24:34
And let’s get a stub in place for a method on reducers: import ComposableArchitecture extension Reducer { func alert( ) -> some ReducerOf<Self> { } }
— 25:07
Now we need to supply some arguments to this method so that it can do its job. Just like the ifLet operator, we will pass along a key path for us to isolate the optional AlertState from our feature’s state: extension Reducer { func alert<Action>( state alertKeyPath: WritableKeyPath< State, AlertState<Action>? > ) -> some ReducerOf<Self> { } }
— 25:45
We also need something similar for actions. Just as the ifLet operator used a case path to isolate the actions for the child, we will do the same, but this time to isolate some alert actions: extension Reducer { func alert<AlertAction>( state alertKeyPath: WritableKeyPath< State, AlertState<AlertAction>? >, action alertCasePath: CasePath<Action, AlertAction> ) -> some ReducerOf<Self> { } }
— 26:17
We can now implement the logic of this reducer operator. We need to return a new reducer, and typically we would do this by creating a new Reducer conformance and returning it from this method: extension Reducer { func alert<Action>( state keyPath: WritableKeyPath< State, AlertState<AlertAction>? >, action casePath: CasePath<Action, AlertAction> ) -> some ReducerOf<Self> { AlertReducer(…) } } struct AlertReducer<…>: Reducer { … }
— 26:40
While more verbose, it does produce more efficient compiled code, and luckily the verbosity is just an implementation detail that can even remain private to the library.
— 26:50
But for the purpose of this episode we won’t concern ourselves with the verbose style, and instead we will just construct a brand new Reduce reducer directly in the method: extension Reducer { func alert<Action>( state keyPath: WritableKeyPath< State, AlertState<AlertAction>? >, action casePath: CasePath<Action, AlertAction> ) -> some ReducerOf<Self> { Reduce { state, action in } } }
— 26:58
All this reducer needs to do is run the parent reducer so that it can process whatever action comes in, but additionally if the action is an alert action, meaning it matches the case path passed in, then we will layer on the additional logic to clear out the alert state: let effects = self.reduce(into: &state, action: action) if alertCasePath ~= action { state[keyPath: alertKeyPath] = nil } return effects
— 27:54
And just like that we have a new, albeit very basic, Composable Architecture navigation tool, and already it fixes one minor annoyance we witnessed a moment ago.
— 28:04
Let’s see this by using the tool.
— 28:06
To use the .alert method we need an actual reducer to act upon, and so we will convert our InventoryFeature to use the body style of reducer rather than the reduce method: var body: some ReducerOf<Self> { Reduce<State, Action> { state, action in … } }
— 28:35
Now we can tack on the alert method at the end of this reducer: var body: some ReducerOf<Self> { Reduce<State, Action> { state, action in … } .alert( state: <#WritableKeyPath<State, AlertState<Action>?>#>, action: <#CasePath<Action, AlertAction>#> ) }
— 28:44
To use this we just need to specify the key path and case path: .alert(state: \.alert, action: /Action.alert)
— 28:57
This compiles, and would work exactly as it did before, but we no longer need to manually clear out alert state in the reducer because the reducer operator handles that for us: //case .alert(.dismiss): // state.alert = nil // return .none case .alert: return .none
— 29:20
It may not seem like a very big win, but at least this is one less thing you can get wrong when trying to show alerts.
— 29:27
In fact, our test already shows the benefit. If we run tests, they fail because we are now getting more state changes when the .confirmDeletion action is sent. We need to further assert that the alert is cleared: await store.send(.alert(.confirmDeletion(id: item.id))) { $0.alert = nil $0.items = [] }
— 29:46
And now it’s no longer necessary to send the .dismiss action just to clear the alert since that is already done for us: // await store.send(.alert(.dismiss)) { // $0.alert = nil // }
— 29:58
And now that we have the basics in place we can start improving it. For example, we are still having to model the dismiss action in our domain and we have to explicitly specify it in the view layer: .alert( self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ), dismiss: .dismiss )
— 30:20
Let’s see if we hide this detail away a bit.
— 30:22
What we can do is have a library-level generic type that wraps any existing type, and “enhances” it with an additional dismiss action: enum AlertAction<Action> { case dismiss case presented(Action) }
— 30:56
By having a dedicated type for alert actions, and by it having a dedicated case for dismiss , we will no longer need to manage the dismiss action ourselves. It will just come along for free by using these APIs.
— 31:08
And because we are going to write tests against this type, we will want to make sure it is Equatable whenever Action is Equatable : extension AlertAction: Equatable where Action: Equatable {}
— 31:18
Now we can cook up a new alert view modifier that speaks the language of this new type, so that it immediately has access to a dismiss action without being handed one explicitly. We’ll first get a stub in place as a method on the View protocol: import SwiftUI extension View { func alert( ) -> some View { } }
— 31:45
It will take a single argument, which is a Store that is focused on just the domain of AlertState and AlertAction : extension View { func alert<Action>( store: Store<AlertState<Action>?, AlertAction<Action>> ) -> some View { } }
— 32:08
We can start by observing the state in the store because we need to be able to derive a binding to drive the alert: extension View { func alert<Action>( store: Store<AlertState<Action>?, AlertAction<Action>> ) -> some View { WithViewStore(store, observe: { $0 }) { viewStore in } } }
— 32:29
And right here we can employ a fun little trick. We are observing all of the alert state because we will need it to show the alert, but we don’t need to recompute this closure every time alert state changes.
— 32:40
In fact, it’s not even possible to change an alert’s title, message, or buttons after it has been presented. This simple demo proves it, which tries to change the message of an alert 2 seconds after it is presented: struct Test: View, PreviewProvider { static var previews: some View { Self() } @State var background = Color.white @State var message = "" @State var isPresented = false var body: some View { ZStack { self.background.edgesIgnoringSafeArea(.all) Button { self.isPresented = true DispatchQueue.main .asyncAfter(deadline: .now() + 2) { self.message = "\(Int.random(in: 0...1_000_000))" self.background = .red } } label: { Text("Press") } .alert( "Hello: \(self.message)", isPresented: self.$isPresented ) { Text("Ok") } } } }
— 33:23
As we can clearly see, the alert is presented, and even though the background eventually changes, the message never does.
— 33:29
So, it’s not necessary to recompute this closure every time the alert state changes. We just need to do it when the alert state flips from nil to non- nil , or vice-versa. To do this we can use WithViewStore ’s removeDuplicates argument: WithViewStore( store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) } ) { viewStore in }
— 34:16
Then inside here we can actually invoke the regular SwiftUI alert view modifier. We could technically even copy-and-paste the existing Composable Architecture alert helper to implement this, but sadly it uses an internal helper that we won’t have access to.
— 34:30
There’s another quick thing we can do. We can actually make use of one of the alert helpers that we defined in our SwiftUINavigation library, and which we discussed extensively on our navigation series of episodes.
— 34:42
Let’s import SwiftUINavigation: import SwiftUINavigation
— 34:47
And let’s add it as a dependency to our project.
— 34:54
And with that we can finish off the view method by using the alert(unwrapping:action:) helper: self.alert( unwrapping: viewStore.binding(send: .dismiss), action: { if let action = $0 { viewStore.send(.presented(action)) } } )
— 35:48
This says that we want to drive the alert from a piece of optional state, which resides inside the viewStore , and if a button is tapped in the alert we will just forward it on to the view store.
— 35:59
But we can take things further. We can also fix the strangeness we saw a moment ago in the simulator where the act of tapping a button in the alert no only sends the action associated with that button, but also follows up with a .dismiss action. This made it impossible to differentiate between an alert closing due to tapping a button with an action versus dismissing it some other way, such as hitting the ESC key or cancel button, and also made tests less strong because we needed to know to send that extra action in order to actually capture what happens in real life.
— 36:28
To fix this we will provide a custom binding to alert that does not send the .dismiss action if the state is already nil when the binding is already written to: self.alert( unwrapping: Binding { viewStore.state } set: { newState in if viewStore.state != nil { viewStore.send(.dismiss) } }, … )
— 37:09
That’s all it takes to define the new-and-improved alert view modifier for the Composable Architecture, and we would hope we can use it like so: .alert( store: self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ) )
— 37:22
There should be no more need to explicitly specify the dismiss action because it has been hidden away inside AlertAction .
— 37:33
But, in order to use this we need to actually make use of AlertAction in our domain, so let’s update the Action enum in our feature: enum Action: Equatable { case alert(AlertAction<Alert>) … }
— 37:49
With that done we need to fix some things in the reducer. First, when reacting to the delete confirmation in the alert we need to further destructure the .presented case: case let .alert(.presented(.confirmDeletion(id: id))): state.items.remove(id: id) return .none
— 38:00
This reads pretty nicely too. It says that when the alert is presented and receives a .confirmDeletion action, we will remove the item from the array.
— 38:06
And our .alert reducer operator is not compiling: .alert(state: \.alert, action: /Action.alert)
— 38:15
…because we started using AlertAction in the feature. To fix this we just need to make the alert reducer operator speak the language of AlertAction : func alert<Alert>( state keyPath: WritableKeyPath< State, AlertState<Alert>? >, action casePath: CasePath< Self.Action, AlertAction<Alert> > ) -> some ReducerOf<Self> {
— 38:42
Now, you may find it a little weird that we completely changed the type of one of the arguments of this method but didn’t have to change anything in the body of the method. And that definitely is weird. It seems like the method isn’t actually using any aspect of AlertAction , and that’s even true of AlertState , so why are we requiring it?
— 39:13
Well, we technically could generalize this operator a bit to not use those types, and that might even open it up to be used in a few other places, but we aren’t going to do that because it turns out this type is not long for this world. We will soon see that this method can be unified with many other forms of navigation, such as sheets, covers, and drill-downs. And when that is done, we will be making more use of these transformations. So, we are going to leave this as-is for now.
— 39:40
But, with those caveats aside, everything is compiling, and it works exactly as it did before, but we have now removed even more annoyance when using alerts in the Composable Architecture. We no longer have to hold onto a dismiss action directly in our domain, or clean up state in the reducer, or even specify that action over in the view. And a two-step process for closing alerts has been turned into a single action.
— 40:40
Oh, and the only change we have to make tests is to further destructure the .presented case await store.send( .alert(.presented(.confirmDeletion(id: item.id))) ) { … }
— 40:53
Tests still pass!
— 41:00
With those few changes done we can run the app in the simulator and see that when we tap a button in the alert, it no longer sends two actions. So now our tests will reflect what really happens in real life. Confirmation dialogs
— 41:08
OK, things are looking pretty great. By building a first class tool for handling the logic of an alert in the domain of a feature, as well as the corresponding view modifier, we were able to simplify some concepts. We no longer need to explicitly model a dismiss action, and instead it comes along free from the library APIs, we don’t need to pass that action around to APIs, and our tests are now stronger and more true-to-life. Brandon
— 41:31
There are still a few annoying things about the API, but in order to see the best shape of these APIs we need more use cases. There’s a quick win we can have which is to see what it takes to support confirmation dialogs. This is the UI that shows a title, message and buttons from the bottom of the screen, and it used to be called action sheets.
— 41:58
The API for showing dialogs in vanilla SwiftUI is basically identical to alerts, and so we would expect the APIs to also look the same in the Composable Architecture. It’s pretty straightforward to do, so let’s give it a shot.
— 42:12
We will add a quick “duplicate” feature to this list by adding another icon button such that when tapped it asks you to confirm duplicating the item. So, we will add a button to each row of the list and when tapped we will send a .duplicateButtonTapped action: Button { viewStore.send(.duplicateButtonTapped(id: item.id)) } label: { Image(systemName: "doc.on.doc.fill") } .padding(.leading)
— 42:48
To get that compiling we need to add an action to our domain: enum Action: Equatable { … case duplicateButtonTapped(id: Item.ID) }
— 42:58
And then we need to handle that action in the reducer: case let .duplicateButtonTapped(id: id): guard let item = state.items[id: id] else { return .none } <#???#> return .none
— 43:17
But, what can we do here?
— 43:19
We want to do something similar to what we did for the alert. We should have a bit of optional state that we can set to a non- nil value to represents presenting the confirmation dialog.
— 43:29
The Composable Architecture comes with a value type description of confirmation dialogs, much like it comes with one for alerts, and it’s called ConfirmationDialogState . So, let’s add another piece of optional state to the feature: struct InventoryFeature: Reducer { struct State: Equatable { var alert: AlertState<AlertAction>? var confirmationDialog: ConfirmationDialogState<…>? … } … }
— 43:47
ConfirmationDialogState is generic over the types of actions that can be sent from the dialog, just like with alerts, so let’s model a new enum just for that: enum Action: Equatable { … enum Dialog: Equatable { case confirmDuplicate(id: Item.ID) } } … var confirmationDialog: ConfirmationDialogState< Action.Dialog >?
— 44:07
And we will need a corresponding case in the action enum too, just like we had for alerts: enum Action: Equatable { case alert(AlertAction<Alert>) case confirmationDialog(???<Dialog>) … }
— 44:15
Now technically we could reuse AlertAction here, but that’d be weird since the name is pretty domain specific to alerts. Or we could try to come up with another, more general name to encapsulate both ideas. Since we are still on the hunt for the right API we don’t want to commit too quickly to a name, and so we are going to go with the path of least resistance by just duplicating the AlertAction type, but renamed for confirmation dialogs: enum Action: Equatable { case alert(AlertAction<Alert>) case confirmationDialog( ConfirmationDialogAction<Dialog> ) … } enum ConfirmationDialogAction<Action> { case dismiss case presented(Action) } extension ConfirmationDialogAction: Equatable where Action: Equatable {}
— 45:16
We’re not saying that duplicating is the correct or best choice. We just don’t want to commit too quickly to a name so that we can see all the ways in which this concept may be used.
— 45:26
Now that we have new actions in the domain we need to handle them in the reducer. When a duplication is confirmed just insert it right near the item being duplicated: case let .confirmationDialog( .presented(.confirmDuplicate(id: id)) ): guard let item = state.items[id: id], let index = state.items.index(id: id) else { return .none } state.items.insert(item.duplicate(), at: index) return .none case .confirmationDialog(.dismiss): return .none
— 47:28
We can also now properly handle the logic for when the duplicate button is tapped. We want to show a confirmation dialog with their options, and we do some in a way that is very similar for alerts, except now we construct a piece of ConfirmationDialogState : case let .duplicateButtonTapped(id: id): guard let item = state.items[id: id] else { return .none } state.confirmationDialog = ConfirmationDialogState { } actions: { } message: { } return .none
— 47:50
But, rather than doing this all here, we are going to extract out a little helper like we did with alerts so that the call site is nice and clean, and tests will be easier to write: state.confirmationDialog = .duplicate(item: item)
— 47:59
And the helper can be copy-and-pasted: extension ConfirmationDialogState where Action == InventoryFeature.Action.Dialog { static func duplicate(item: Item) -> Self { Self { TextState(#"Duplicate "\#(item.name)""#) } actions: { ButtonState( action: .send( .confirmDuplicate(id: item.id), animation: .default ) ) { TextState("Duplicate") } } message: { TextState( "Are you sure you want to duplicate this item?" ) } } }
— 48:58
OK, we’ve implement the core feature of duplication, but now we need to make use of a reducer operator to layer on the functionality of automatically dismissing the dialog when an action is sent, just like what we did for alerts. We’d love if it looked the same: .alert(state: \.alert, action: /Action.alert) .confirmationDialog( state: \.confirmationDialog, action: /Action.confirmationDialog )
— 49:28
Well, to accomplish this we can just copy-and-paste the alert reducer operator and make a few small changes to its signature: extension Reducer { func confirmationDialog<DialogAction>( state keyPath: WritableKeyPath< State, ConfirmationDialogState<DialogAction>? >, action casePath: CasePath< Action, ConfirmationDialogAction<DialogAction> > ) -> some ReducerOf<Self> { … } }
— 49:59
We don’t even need to change the body of this method. Just the signature.
— 50:08
That is just yet more proof that we are missing out on a generalization to unite these things, but we are going to hold off for a bit longer.
— 50:17
That gets things compiling, but now we have to make some changes to the view.
— 50:25
What would be really amazing is if we had access to a confirmationDialog view modifier that looked like the alert one. Something like this: .alert( store: self.store.scope( state: \.alert, action: InventoryFeature.Action.alert ) ) .confirmationDialog( store: self.store.scope( state: \.confirmationDialog, action: InventoryFeature.Action.confirmationDialog ) )
— 50:47
Well, that’s possible, and it’s quite straightforward because we can copy-and-paste the alert modifier we wrote a few moments ago and make a few small changes: extension View { func confirmationDialog<Action>( store: Store< ConfirmationDialogState<Action>?, ConfirmationDialogAction<Action> > ) -> some View { WithViewStore( store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) } ) { viewStore in self.confirmationDialog( unwrapping: Binding { viewStore.state } set: { newState in if newState == nil, viewStore.state != nil { viewStore.send(.dismiss) } }, action: { if let action = $0 { viewStore.send(.presented(action)) } } ) } } }
— 51:16
Now everything compiles and the feature actually works. We can now tap the duplicate icon, we get a confirmation dialog, and then confirming duplication inserts a new row into the list.
— 52:17
And of course everything is 100% testable. We can quickly sketch out a test that proves that the duplication flow really does result in an item being duplicated. We can start the test in much the same way that we tested the delete flow: func testDuplicate() async { let item = Item.headphones let store = TestStore( initialState: InventoryFeature.State(items: [item]), reducer: InventoryFeature() ) }
— 52:45
But then we will emulate the user tapping the duplicate button, which should make the confirmation dialog appear: await store.send(.duplicateButtonTapped(id: item.id)) { $0.confirmationDialog = .duplicate(item: item) }
— 53:14
And then we can emulate the user confirming to duplicate, which will cause the dialog to go away, and then also the items array will change: await store.send( .confirmationDialog( .presented(.confirmDuplicate(id: item.id)) ) ) { $0.confirmationDialog = nil $0.items = [ <#???#> ] }
— 53:43
…but I’m not entirely sure how to assert on how the items will change. I know the duplicated item will be inserted at the same spot where the original item stood, so I guess I could do something like this: $0.items = [ item.duplicate(), item ]
— 54:10
But running the tests we get a failure: testDuplicate(): A state change does not match expectation: … InventoryFeature.State( alert: nil, confirmationDialog: nil, items: [ [0]: Item( id: UUID( − 4374D0CB-FA00-4BCA-A33D-452C5470B973 + 23CF4868-53B3-4D70-ADEE-8BA2FFB6F734 ), name: "Headphones", color: Item.Color(…), status: .inStock(quantity: 20) ), [1]: Item(…) ] ) (Expected: −, Actual: +)
— 54:14
The diff in the failure is very small, so we nearly got everything right. The only thing that is wrong is its ID.
— 54:31
And this shouldn’t be too surprising. The act of duplicating an item means generating a new item that has all of the same data, except its ID should be new because it should be a completely new, distinct item: public func duplicate() -> Self { Self( name: self.name, color: self.color, status: self.status ) }
— 54:43
And in particular, when creating an Item , if you do not specify an ID it will default to a randomly generated UUID: public init( id: UUID? = nil, … ) { self.id = id ?? UUID() … }
— 54:51
This is a really gnarly dependency to have infecting your code that can make testing difficult.
— 54:58
We never controlled this dependency in our vanilla SwiftUI navigation series because we could always just ignore that bit of state when making assertions, but the Composable Architecture makes us be a lot more exhaustive with our testing.
— 55:12
So, let’s make use of our new dependency management library , which comes transitively from the Composable Architecture, to control this dependency and fix our tests.
— 55:21
Rather than plucking a new
UUID 55:51
Now this may look a little strange to our viewers. Is it legit to use @Dependency(\.uuid) inside an initializer of a data type? We only ever showed off using the @Dependency property wrapper in Reducer conformances, never in initializers.
UUID 56:07
Well, this absolutely is OK, at least for the Composable Architecture. As we briefly discussed in past episodes, the Composable Architecture forms what is known as a “single entry point system”, and that makes it safe to use the @Dependency property wrapper almost anywhere. This set up would not be safe in a non-”single entry point” system, such as with observable objects and UIKit. We have a very detailed article on “single entry point” systems in our dependency library documentation, and we highly recommend every go read that.
UUID 56:45
With that said, it is totally fine to do, and with that change the application should work exactly as it did before, but we now have a new test failure when we run tests: testDuplicate(): @Dependency(\.uuid) has no test implementation, but was accessed from a test context: Location: Inventory/Models.swift:16 Key: DependencyValues.UUIDGeneratorKey Value: UUIDGenerator Dependencies registered with the library are not allowed to use their default, live implementations when run from tests. To fix, override ‘uuid’ with a test value. If you are using the Composable Architecture, mutate the ‘dependencies’ property on your ‘TestStore’. Otherwise, use ‘withDependencies’ to define a scope for the override. If you’d like to provide a default value for all tests, implement the ‘testValue’ requirement of the ‘DependencyKey’ protocol.
UUID 57:22
This is letting us know that we are now interacting with the live
UUID 57:43
But, the mere fact that we are getting this error also means we are now in a position to actually fix it. We override the uuid dependency in the test so that when generating a new UUID it produces something predictable and deterministic, like an auto-incrementing value: let store = TestStore(…) store.dependencies.uuid = .incrementing
UUID 58:19
You can even override this dependency and construct the store all in one step: let store = TestStore( initialState: InventoryFeature.State(items: [item]), reducer: InventoryFeature() ) { $0.uuid = .incrementing }
UUID 58:34
Now we know that the first time the uuid() dependency is used it will simply generate a UUID that has all 0 s, and so we can now predict exactly what kind of item will be added to the array: await store.send( .confirmationDialog( .presented(.confirmDuplicate(id: item.id)) ) ) { $0.confirmationDialog = nil $0.items = [ Item( id: UUID( uuidString: "00000000-0000-0000-0000-000000000000" )!, name: item.name, color: item.color, status: item.status ), item, ] }
UUID 59:28
And this test now passes! Getting test coverage on both alerts and confirmation dialogs is very simple, and testing each looks very similar. Next time: sheets
UUID 59:40
OK, things are looking pretty good. We’ve cooked up new reducer operators that can hide away details, such as having an explicit dismiss action that can be sent when wanting to get rid of an alert, as well as automatically clearing out state when an alert is interacted with. It requires a little bit of upfront work to make use of this API, but the benefits really start to roll in once we consider testing. The Composable Architecture keeps us in check every step of the way to make sure that we are asserting on how your feature evolves.
UUID 1:00:13
But, there are still some things to not really like about what we are doing. The main thing is that when we went to expand this API to also include confirmation dialogs, which for all intents and purposes are basically equivalent to alerts, we just copy and pasted everything and renamed it. That’s a pretty big bummer, and there should of course be a way to unify those two things.
UUID 1:00:32
Also, we are currently modeling the alert and confirmation dialog as two separate pieces of optional state, which as we’ve seen time and time again on Point-Free, is not ideal. This allows for non-sensical combinations of state, such as both being non- nil . Ideally we should be able to model destinations as a single enum so that we have compile-time proof that only one destination can be active at a time. Stephen
UUID 1:01:02
But we aren’t going to address any of that just yet. If we do just a little bit more exploring we can come up with another form of navigation, and seeing these shapes for a third time will help us solidify an API that goes well beyond just alerts and dialogs.
UUID 1:01:15
And that next form of navigation is sheets.
UUID 1:01:18
Sheets are a great example of state-driven navigation in SwiftUI. They can be powered by some boolean state to control showing or hiding a view, but even more powerful is to control presentation with a piece of optional state, so that when the state becomes non- nil the view is presented, and when it turns nil it is dismissed.
UUID 1:01:34
We want to bring this power to features built in the Composable Architecture, but instead of mere optional state powering navigation, we want the optionality of an entire feature’s domain to control presentation.
UUID 1:01:44
In particular, the feature we want to show in a sheet is the “add item” view. This is the view that allows you to fill out the details for a new item to add to the inventory, and then you can either add it or discard it.
UUID 1:01:58
Let’s try implementing this feature with just the tools that the Composable Architecture gives us, see why they are lacking, and then we will fix them…next time! References Single entry point systems Brandon Williams & Stephen Celis Learn about “single entry point” systems, and why they are best suited for our dependencies library , although it is possible to use the library with non-single entry point systems. https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/singleentrypointsystems 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 0223-composable-navigation-pt2 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 .