Video #96: Adaptive State Management: Actions
Episode: Video #96 Date: Mar 30, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep96-adaptive-state-management-actions

Description
When we fixed a performance problem in the Composable Architecture it gave us an opportunity to adapt the state of our application to many situations. We’ll take these learnings to see how our application’s actions may adapt as well.
Video
Cloudflare Stream video ID: 40a4aa8700f682a34c5bfde1f02b332a Local file: video_96_adaptive-state-management-actions.mp4 *(download with --video 96)*
Transcript
— 0:05
If there’s one thing we hope you’ve learned on Point-Free it’s that when you have complimentary concepts, such as state and action or struct and enum, as soon as you find something handy for one concept you should immediately look for the equivalent on the other concept. In general that’s just a great principle to live by. And currently our ViewStore is kind of lopsided, in that we are only focusing on the application state when we use a view store. That’s understandable since the whole motivation for the view store was to minimize what state our views know about in order to improve performance, but there’s this other side of our application: the actions!
— 0:44
By extending our notion of the view store to also account for the actions that a view cares about we will be able to further chisel away at the domain that the view has access to. To see why that would be useful, let’s take a look at the CounterView again. Action adaptation
— 1:19
Right now, we have this really wonderful property in our view that inside each of the action closures of a UI component we don’t do any logic whatsoever, we only send an action to the store. This is great because if we naively build a vanilla SwiftUI view we will put all types of messy logic and state mutation in those closures, and it will obscure all the things this view is responsible for. Further, the actions we send to the store describe precisely what happened to cause those actions to be sent. So, we don’t describe them in terms of what we want to change about our application, like “increment count”, “request nth prime”, “dismiss alert”, but instead we only describe what the user did, like “increment button tapped”, “nth prime button tapped” and “prime modal dismissed” and then let the reducer interpret those actions: self.store.send(.counter(.decrTapped)) self.store.send(.counter(.incrTapped)) self.store.send(.counter(.isPrimeButtonTapped)) self.store.send(.counter(.nthPrimeButtonTapped)) self.store.send(.counter(.primeModalDismissed)) self.store.send(.counter(.alertDismissButtonTapped))
— 2:16
This makes our views very simple and doesn’t leave a lot of room for interpretation on our part when trying to understand the purpose of these various UI component actions. After all, 6 months from now the actions from these UI elements could be doing a lot more, like tracking analytics, starting timers, and who knows what else. So in general it’s far better to describe what the user did in the action name rather than what changes are going to be made to state or what effects are going to be executed.
— 2:42
However, the CounterAction enum does hold some actions that are not directly a UI action, but rather are an action that is fed into the system from an effect. For example, when we receive the response from the Wolfram Alpha API: case nthPrimeResponse(n: Int, prime: Int?)
— 3:03
This leaves us open to doing nonsensical things in our view like: Button( "What is the \(ordinal(self.viewStore.value.count)) prime?" ) { self.store.send(.counter(.nthPrimeResponse(n: 7, prime: 17))) // self.store.send(.counter(.nthPrimeButtonTapped)) }
— 3:20
We would never want to send this action in the view because it should only come from having executed an effect, in particular the Wolfram Alpha API request.
— 3:28
Further, what if there were a bunch of user actions that all do the same thing. For example, perhaps there are multiple ways to ask for the “nth prime”. We hear that gestures are all the rage, so maybe if you double tap on this screen it will ask for the “nth prime”. This means we need to add another action to the CounterAction enum, then need to handle that action in the reducer even though it’s going to do exactly what the other action does: public enum CounterAction: Equatable { … case doubleTap
— 3:59
We need to handle that action in the reducer, which naively could be just repeating what the current nthPrimeButtonTapped does: case .doubleTap: state.isNthPrimeRequestInFlight = true return [ Current.nthPrime(state.count) .map(CounterAction.nthPrimeResponse) .receive(on: DispatchQueue.main) .eraseToEffect() ]
— 4:12
We of course wouldn’t want to recreate this work so blatantly, so we could instead extract this work out into a little mini-reducer that each of the actions call out to, or we could even just handle both actions together at the same time: case .nthPrimeButtonTapped, .doubleTap:
— 4:30
This latter technique is probably the most direct, but unfortunately it falls apart if we need to bind some data in these actions and the data doesn’t match exactly. Like say one of these actions held an integer and the other held a boolean. Then we couldn’t handle both actions at the same time and we’d be forced to separate them.
— 4:46
To hook up this functionality in the view we can add a double tap gesture recognizer to our root view, and then send the new action to the store when its invoked: .onTapGesture(count: 2) { self.store.send(.counter(.doubleTap)) }
— 5:12
If we run the app we will see that it doesn’t quite right as we would expect. If we double click anywhere in the white area of the screen, we don’t trigger the onTapGesture action like we would expect. This is because the gesture only works on the individual views inside the root VStack , so really the only way to trigger this would be to double tap on the little text that shows the count.
— 5:41
A quick, hacky fix is to wrap the VStack in a view that fills the entire screen: .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity )
— 5:49
However, without doing anything with that new wrapper view SwiftUI will optimize it to not even create an actual UIView , so we have to further do something with it, like give it a background color: .background(Color.white)
— 6:01
This is maybe not the best way to do this, but we aren’t in the business of teaching the best way to utilize SwiftUI right now, we just need to get something working.
— 6:14
And if we run the app again we will now finally see that when you double tap anywhere in the white area of the screen we do indeed invoke the “nth prime” effect to get our result.
— 6:28
We are starting to see some annoyances with this that are quite similar to what we saw with state. First, we saw that there are actions available to this view that the view doesn’t really care about, which is exactly what we saw with state. For state that meant we were over-computing our views, but for actions it means we can do bad things like send actions that aren’t meant to be sent from the view. Then, we saw that we often want hyper domain-specific actions for our view, and it will be a pain to add all of those actions to the domain. For state we had a similar problem in which we wanted to add support for disabling more parts of the UI, but that required adding even more state to our struct. So, there’s a kind of duality between the problems we see with using the state in a store and sending the actions in a store. View store action sending
— 7:10
So, perhaps we should fix this by removing the notion of sending actions to the store, and instead only use the view store for sending actions, since that gives us a nice place to do transformations that are view-specific. The store will still be responsible for handling the work that needs to be done with an action is sent, such as running the reducer and running the effects that the reducer, but we just won’t be sending actions directly to the store.
— 7:35
So, let’s start by making the send method on Store private so that we are not capable of sending it actions directly. private func send(_ action: Action) {
— 8:05
And then we want to add a send method to the ViewStore , which means the ViewStore also needs a new generic: public final class ViewStore<Value, Action>: ObservableObject { … public func send(_ action: Action) { <#???#> } }
— 8:20
When this send method is invoked it should really just be calling out to the store from which this view store was derived. This is because the store is the real runtime that is responsible for handling actions. The view store send is just a layer we are putting between the store and the user of the store so that we can control the shape of actions we are using.
— 8:37
This means that rather than having a send method on ViewStore we instead want a property that holds a send function so that it can be externally customized upon creation of the view store: public final class ViewStore<Value, Action>: ObservableObject { @Published public fileprivate(set) var value: Value fileprivate var cancellable: Cancellable? public let send: (Action) -> Void init( initialValue: Value, send: @escaping (Action) -> Void ) { self.value = initialValue self.send = send } }
— 9:11
This causes a compiler error for our view function because we have changed what it takes to create a view store. But it’s very easy to fix, we will just introduce an Action generic pass along the send function from the store: extension Store { public func view( removeDuplicates predicate: (Value, Value) -> Bool ) -> ViewStore<Value, Action> { let viewStore = ViewStore( initialValue: self.value, send: self.send ) viewStore.viewCancellable = self.$value .removeDuplicates(by: predicate) .sink { newValue in viewStore.value = newValue self } return vs } }
— 9:35
the Composable Architecture now builds, but before moving on we can clean something up. Remember that earlier we added this self capture to the sink closure so that the view store would hold onto the store that it was derived from. This was necessary so that if we first scoped a store and then viewed it we didn’t lose the intermediate store that was created. Well, we can now get rid of that because the view store now properly holds onto the store since we are passing self.send to the initializer. .sink { newValue in viewStore.value = newValue // self }
— 10:07
So it’s nice that the ownership relation between these two objects is natural once we make the view store responsible for sending actions too.
— 10:14
To get the rest of the app building we can do the simplest thing which is to just not transform the action just yet.
— 10:53
For example, in the PrimeModal module we need to do the following: public struct IsPrimeModalView: View { … @ObservedObject private var viewStore: ViewStore<State, PrimeModalAction> … public var body: some View { … self.viewStore.send(.removeFavoritePrimeTapped) … self.viewStore.send(.saveFavoritePrimeTapped) … } }
— 11:34
That gets this module compiling.
— 11:53
Similarly, small changes can be made to the FavoritePrimes module to get it compiling: public struct FavoritePrimesView: View { @ObservedObject var viewStore: ViewStore<[Int], FavoritePrimesAction> … public var body: some View { … self.viewStore.send(.deleteFavoritePrimes(indexSet)) … self.viewStore.send(.saveButtonTapped) … self.viewStore.send(.loadButtonTapped) … } }
— 12:32
And finally similar changes to the Counter module will get the whole app building again: public struct CounterView: View { @ObservedObject private var viewStore: ViewStore<State, CounterFeatureAction> … public var body: some View { … self.viewStore.send(.counter(.decrTapped)) … Button("+") { self.viewStore.send(.counter(.incrTapped)) } … Button("Is this prime?") { self.viewStore.send(.counter(.isPrimeButtonTapped)) } … self.viewStore.send(.counter(.nthPrimeButtonTapped)) … onDismiss: { self.viewStore.send(.counter(.primeModalDismissed)) } … self.viewStore.send(.counter(.alertDismissButtonTapped)) … } } View actions
— 13:12
And now our application is building, but we of course aren’t leveraging the power of action transformations in the view store. We hope that they will give us the opportunity to not only hide some actions from the view that it doesn’t need to worry about, but also allow us to consolidate lots of similar actions while still leaving us open to having domain-specific names for the actions.
— 13:37
In particular, let’s look at the counter actions again, because we have an action that is not necessary for the view to know about, and we have two actions that ultimately lead to the same behavior in the reducer. We hope that we can improve this situation using our new view store adaptation tool. As we mentioned before, from the reducer’s perspective it’s a little strange to keep introducing more and more actions that under the hood do the same thing, whereas from the perspective of the view it makes perfect sense since it helps our view be as simple as possible.
— 14:16
This disparity between view logic and reducer logic is precisely what the view store can help us solve. We should be allowed to come up with names for actions that make the most sense for the domain of the business logic, while also using action names that make sense for the view.
— 14:31
To start, let’s reshape the action domain of the feature so it better describes the business logic. Instead of having a separate action for nthPrimeButtonTapped and doubleTap , let’s create a single action that can be used to request the “nth prime”: public enum CounterAction: Equatable { … // case nthPrimeButtonTapped: // case doubleTap: case requestNthPrime:
— 14:51
Then we need to update the reducer: // case .nthPrimeButtonTapped, .doubleTap case .requestNthPrime: state.isNthPrimeButtonDisabled = true return [ Current.nthPrime(state.count) .map(CounterAction.nthPrimeResponse) .receive(on: DispatchQueue.main) .eraseToEffect() ]
— 15:01
And just to get things compiling we could further just send the .requestNthPrime action everywhere in the view: self.viewStore.send(.requestNthPrime)
— 15:11
This of course is not ideal because there’s a disconnect between what the user did and what we are sending to the store. The view could be made simpler if it only concerned itself with telling the store exactly what the user did, and then let the store interpret that to mean requesting the “nth prime”.
— 15:27
So, just like we did for state, let’s cook up an enum of domain-specific actions for this view. What are all the things that can happen in this view? It’s basically everything in the CounterAction enum, but with some actions added and some removed: enum Action { case decrTapped case incrTapped case nthPrimeButtonTapped case alertDismissButtonTapped case isPrimeButtonTapped case primeModalDismissed case doubleTap } In particular, we now separate requestNthPrime into two actions for nthPrimeButtonTapped and doubleTap , and we no longer have an action for nthPrimeResponse since that isn’t ever sent from the view.
— 16:16
To make use of this we’d like the viewStore in our view to no longer work with CounterAction s, but instead just use this internal Action : @ObservedObject private var viewStore: ViewStore<State, Action> This creates a bunch of errors: in our view store transformation, and in a bunch of spots in the body property where we are sending CounterFeatureAction s instead of this new kind of action.
— 16:23
The latter errors are the easiest to fix. We just need to remove the .counter wrapper from the actions we are sending, and we can now send the domain-specific action that we tapped the “nth prime” button or that we double tapped.
— 17:07
To fix the view store transformation we just need to describe how transform an incoming, internal action into the CounterFeatureAction that the reducer actually works with. This little bit of translation logic can happen directly in the view invocation: self.viewStore = self.store .view( value: primeModalViewState, action: { switch $0 { case .decrTapped: return .counter(.decrTapped) case .incrTapped: return .counter(.incrTapped) case .nthPrimeButtonTapped: return .counter(.requestNthPrime) case .alertDismissButtonTapped: return .counter(.alertDismissButtonTapped) case .isPrimeButtonTapped: return .counter(.isPrimeButtonTapped) case .primeModalDismissed: return .counter(.primeModalDismissed) case .doubleTap: return .counter(.requestNthPrime) } }, removeDuplicates: == ) This translation is quite simple, we simply consider each local, domain-specific action and determine what it means for the reducer’s business logic.
— 19:14
Of course, this has really ballooned our initializer’s code, and so just like we did for state, we could put this in a little helper function, but this time it needs to convert our local Action into the CounterFeatureAction . So we choose to represent this as a static function on the local Action enum: extension CounterFeatureAction { init( counterViewAction action: CounterView.Action ) -> CounterFeatureAction { switch action { case .decrTapped: self = .counter(.decrTapped) case .incrTapped: self = .counter(.incrTapped) case .nthPrimeButtonTapped: self = .counter(.requestNthPrime) case .alertDismissButtonTapped: self = .counter(.alertDismissButtonTapped) case .isPrimeButtonTapped: self = .counter(.isPrimeButtonTapped) case .primeModalDismissed: self = .counter(.primeModalDismissed) case .doubleTap: self = .counter(.requestNthPrime) } } }
— 19:57
And then we can update our view’s initializer. public init( store: Store<CounterFeatureState, CounterFeatureAction> ) { print("CounterView.init") self.store = store self.viewStore = self.store .scope( value: State.init, action: Action.init ) .view(removeDuplicates: ==) }
— 20:30
And we have now accomplished something pretty cool. Let’s take a step back to see how our view has shaped up after this work.
— 20:40
The view starts by declaring a struct and enum for its own little local domain, just the pieces of data that it cares about in order for the body property to do its job: public struct CounterView: View { typealias State = ( alertNthPrime: PrimeAlert?, count: Int, isNthPrimeButtonDisabled: Bool, isPrimeModalShown: Bool ) enum Action { case decrTapped case incrTapped case nthPrimeButtonTapped case alertDismissButtonTapped case isPrimeButtonTapped case primeModalDismissed case doubleTap } … } Notice that all of these properties and cases are specifically tuned for UI concerns. We are describing state as it is represented directly in the UI, such as when an alert or modal is show, and when a button is disabled. And we are describing actions as exactly what the user can do in the UI, such as tapping a button, dismissing a modal, or double tapping the screen. There should be very little room for interpretation between the names of the fields and cases and what they represent in the UI.
— 21:11
Next we have the runtime objects that powers this view: let store: Store<CounterFeatureState, CounterFeatureAction> @ObservedObject var viewStore: ViewStore<State, Action>
— 21:16
First the store, which is the runtime that actually runs our business logic and effects, and its domain holds everything that this feature, as well as all the child features need to do their jobs. Then we have the view store, which powers the rendering of this view. We read the current state in order to implement the body property for constructing the UI, and we send it user actions so that our business logic is run.
— 21:49
Next we have the initializer, which only requires a store to be passed in from the parent, and then we view into that store in order to extract out only the base essentials of state and actions that the view actually cares about. We even cleaned this up a bit by extracting the state and action transformations into helper functions that everything is nice and tidy in the initializer: public init( store: Store<CounterFeatureState, CounterFeatureAction> ) { self.store = store self.viewStore = self.store .scope( value: State.init, action: CounterFeatureAction.init ) .view }
— 22:01
Then in the body property, where the real work for the UI happens, we have something very straightforward and pretty much entirely logicless. For example, to show the core UI of the counter and buttons we have: VStack { HStack { Button("-") { self.viewStore.send(.decrTapped) } .disabled(self.viewStore.value.isDecrementButtonDisabled) Text("\(self.viewStore.value.count)") Button("+") { self.viewStore.send(.incrTapped) } .disabled(self.viewStore.value.isIncrementButtonDisabled) } Button("Is this prime?") { self.viewStore.send(.isPrimeButtonTapped) } Button( "What is the \(ordinal(self.viewStore.value.count)) prime?" ) { self.viewStore.send(.nthPrimeButtonTapped) } .disabled(self.viewStore.value.isNthPrimeButtonDisabled) } The buttons just send actions to the view store, and those actions exactly describe what cause them, so there’s no room for interpretation of what action we should send. Similarly, reading the state from the view store for constructing these UI components is also pretty straightforward.
— 22:39
If we were so inclined, we could maybe even remove this little bit of logic for the button: Button( "What is the \(ordinal(self.viewStore.value.count)) prime?" ) {
— 22:43
Why do this computation in the view when we could instead move it to the view store? Button(self.viewStore.value.nthPrimeButtonTitle) {
— 22:53
Then we just need to update our local view state. public struct CounterView: View { struct State: Equatable { let alertNthPrime: PrimeAlert? let count: Int let isDecrementButtonDisabled: Bool let isIncrementButtonDisabled: Bool let isNthPrimeButtonDisabled: Bool let isPrimeModalShown: Bool let nthPrimeButtonTitle: String } … } extension CounterView.State { init(counterFeatureState state: CounterFeatureState) { self.alertNthPrime = state.alertNthPrime self.count = state.count self.isDecrementButtonDisabled = state.isNthPrimeRequestInFlight self.isIncrementButtonDisabled = state.isNthPrimeRequestInFlight self.isNthPrimeButtonDisabled = state.isNthPrimeRequestInFlight self.isPrimeModalShown = state.isPrimeModalShown self.nthPrimeButtonTitle = """ What is the \(ordinal(state.count)) prime? """ } }
— 23:34
Continuing on in the view we see more complex types of UI that are still quite simple because they have no logic. For example, to show and hide the prime modal we do: .sheet( isPresented: .constant(self.viewStore.value.isPrimeModalShown), onDismiss: { self.viewStore.send(.primeModalDismissed) } ) { IsPrimeModalView( store: self.store.scope( value: { ($0.count, $0.favoritePrimes) }, action: { .primeModal($0) } ) ) } Again the view store’s state perfectly describes when the modal should be shown, and what to do when the modal is dismissed. And then the modal view presented describes exactly what kind of store it needs to do its job, and we can pass it that store by scoping our store.
— 24:33
Next we have the “nth prime” alert: .alert( item: .constant(self.viewStore.value.alertNthPrime) ) { alert in Alert( title: Text(alert.title), dismissButton: .default(Text("OK")) { self.viewStore.send(.alertDismissButtonTapped) } ) } Again the state precisely describes when to show the alert, and the action describes that it should be sent when the alert’s dismiss is tapped.
— 25:22
And finally we have the tap gesture: .onTapGesture(count: 2) { self.viewStore.send(.doubleTap) } Which again, the action describes exactly when it should be sent. No guessing or wondering.
— 25:28
And so what we are seeing here is that there is a very direct recipe for constructing our views so that they work with the Composable Architecture and so that they contain as little logic as possible.
— 25:39
First you describe the local domain of the view with a struct of state and an enum of actions.
— 25:46
Then you describe the runtime of the view by determining the domain of the store that powers the business logic for this view, and all of its subviews, along with the domain of the view store that powers just the local domain of this view.
— 26:04
Next you implement the initializer for your view, which will take a store as an argument, and then in the initializer you need to describe how to transform the entire feature’s domain into the view’s domain.
— 26:20
And finally you need to implement the body of the view, which should do as little work as possible to turn the state of the view store into UI. Tests and the view store
— 26:50
Alright, we’ve made some pretty sweeping changes to the architecture. Before moving any further we should consider any impacts they have had on the architecture’s testing story.
— 27:22
If we try to build and run the favorite primes tests, we’ll fine that not only does it still build, but everything still passes, as well. This makes sense because the changes we made to the architecture were all around Store s and ViewStore s. These tests are simple unit tests that invoke the reducer directly with some state and actions, neither of which have changed since last time.
— 27:53
And the same is true for the prime modal tests: everything builds and passes. This is pretty cool! It means that the testing story for reducers hasn’t fundamentally changed.
— 28:08
So what about the counter tests? Unfortunately they are no longer in building order. Not only have we done some significant domain remodeling in the counter module, but we have an integration-style test that creates a view and a store and snapshot tests the counter screen.
— 28:34
Let’s start with the unit tests. The counter module’s unit tests use the Composable Architecture’s super-powered assert helper, which can, in a very declarative manner, describe a series of steps a user takes in our application and assert how the state mutates after each step, all the while hiding the nitty gritty details of managing and running side effects.
— 29:12
Our first error is in a test that asserts against pressing the increment and decrement buttons because it’s still referring to CounterViewState , which we previously renamed to CounterFeatureState . func testIncrDecrButtonTapped() { assert( initialValue: CounterFeatureState(count: 2), reducer: counterFeatureReducer, environment: { _ in .sync { 17 } }, steps: Step(.send, .counter(.incrTapped)) { $0.count = 3 }, Step(.send, .counter(.incrTapped)) { $0.count = 4 }, Step(.send, .counter(.decrTapped)) { $0.count = 3 } ) }
— 29:24
The next error is in a test that describes the “happy flow” of asking for the “nth” prime. We have remodeled the core domain logic so that it is no longer correct to assert against the isNthPrimeButtonDisabled state or the nthPrimeButtonTapped action. Instead we can describe this state and action more abstractly with isNthPrimeRequestInFlight and requestNthPrime . func testNthPrimeButtonHappyFlow() { assert( initialValue: CounterFeatureState( alertNthPrime: nil, count: 7, isNthPrimeRequestInFlight: false ), reducer: counterFeatureReducer, environment: { _ in .sync { 17 } }, steps: Step(.send, .counter(.requestNthPrime)) { $0.isNthPrimeRequestInFlight = true }, Step(.receive, .counter(.nthPrimeResponse(n: 7, prime: 17))) { $0.alertNthPrime = PrimeAlert(n: $0.count, prime: 17) $0.isNthPrimeRequestInFlight = false }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } ) }
— 30:21
The “unhappy flow” needs to make the same changes: func testNthPrimeButtonUnhappyFlow() { assert( initialValue: CounterFeatureState( alertNthPrime: nil, count: 7, isNthPrimeRequestInFlight: false ), reducer: counterFeatureReducer, environment: { _ in .sync { nil } }, steps: Step(.send, .counter(.requestNthPrime)) { $0.isNthPrimeRequestInFlight = true }, Step(.receive, .counter(.nthPrimeResponse(n: 7, prime: nil))) { $0.isNthPrimeRequestInFlight = false } ) }
— 30:47
And finally we need to update testPrimeModal to use CounterFeatureState instead of CounterViewState .
— 30:57
And just like that our unit tests are compiling again! If we’re diligent with our use of Xcode’s refactoring tools, this kind of work can even be automatic.
— 31:19
One thing we should mention is how these tests no longer describe in very specific terms the actions a user takes or the state that a screen holds. Before these changes, these tests described exactly what the user did because the action mapped directly to the nth prime button being tapped, and the state mapped directly to the nth prime button being disabled. We’ve now generalized things to work across platforms, where the specific user actions and screen state may diverge a bit. It’s important to point out, though, that this assert helper still does a very good job of describing and testing a script of user intents, which just happen to map to different UI on different platforms.
— 32:11
Next up we have the testSnapshots test case, which is an integration-style test in which we created a store, a view, and sent a script of actions along to that store, asserting against screen shots along the way.
— 32:45
But with the changes we’ve made to the architecture we can no longer call send directly on a store: store.send(.counter(.incrTapped)) ‘send’ is inaccessible due to ‘private’ protection level
— 32:55
One thing we could do is make Store ’s send method internal so that we get access to it with an @testable import. Then we could continue to send feature-level CounterFeatureAction s actions along to the store. Perhaps easier, we could tack a .view onto the store to get access to its view store. let viewStore = store.view
— 33:16
And then update all our calls that send actions to the store to send them to the view store instead: viewStore.send(.counter(.incrTapped)) … viewStore.send(.counter(.incrTapped)) … viewStore.send(.counter(.nthPrimeButtonTapped)) … viewStore.send(.counter(.alertDismissButtonTapped)) … viewStore.send(.counter(.isPrimeButtonTapped)) … viewStore.send(.primeModal(.saveFavoritePrimeTapped)) … viewStore.send(.counter(.primeModalDismissed))
— 33:26
And now we’re down to a single error: viewStore.send(.counter(.nthPrimeButtonTapped)) Type ‘CounterAction’ has no member ‘nthPrimeButtonTapped’
— 33:32
We just need to update nthPrimeButtonTapped to requestNthPrime and we’re in building order again. And when we run our tests: failed - Snapshot does not match reference.
— 33:45
We get a single snapshot assertion failure. If we look at the diff, that makes sense.
— 34:01
We’ve updated our views to disable the incr and decr buttons while an nth prime request is in flight, and the snapshot is correctly capturing this behavior. So we can re-record things. record = true failed - Record mode is on. Turn record mode off and re-run “testSnapshots” to test against the newly-recorded snapshot.
— 34:28
…turn off record mode…
— 34:43
And this time tests pass!
— 34:47
There is something more to say about these tests, though, because we’ve lost some coverage. In the counter module we have a few functions that map CounterFeatureState and CounterFeatureAction to CounterView.State and CounterView.Action . extension CounterView.State { init(counterFeatureState state: CounterFeatureState) { self.alertNthPrime = state.alertNthPrime self.count = state.count self.isDecrementButtonDisabled = state.isNthPrimeRequestInFlight self.isIncrementButtonDisabled = state.isNthPrimeRequestInFlight self.isNthPrimeButtonDisabled = state.isNthPrimeRequestInFlight self.isPrimeModalShown = state.isPrimeModalShown self.nthPrimeButtonTitle = """ What is the \(ordinal(state.count)) prime? """ } } extension CounterFeatureAction { init( counterViewAction action: CounterView.Action ) -> CounterFeatureAction { switch action { case .decrTapped: return .counter(.decrTapped) case .incrTapped: return .counter(.incrTapped) case .nthPrimeButtonTapped: return .counter(.requestNthPrime) case .alertDismissButtonTapped: return .counter(.alertDismissButtonTapped) case .isPrimeButtonTapped: return .counter(.isPrimeButtonTapped) case .primeModalDismissed: return .counter(.primeModalDismissed) case .doubleTap: return .counter(.requestNthPrime) } } }
— 35:15
And none of our tests have coverage against these transformations.
— 35:34
Now, these functions and data types are quite simple so it’s reasonable to predict that they are quite simple to unit test.
— 35:41
In our snapshot tests, however, it would be nice to know that the view state is changing directly from a script of view actions. So instead of a viewStore on the entire counter feature’s state and actions, maybe it’d be better to create a counter-scoped view store instead. let counterViewStore = store .scope( value: CounterView.State.init, action: CounterFeatureAction.init) ) .view … counterViewStore.send(.incrTapped) … counterViewStore.send(.incrTapped) … counterViewStore.send(.nthPrimeButtonTapped) … counterViewStore.send(.alertDismissButtonTapped) … counterViewStore.send(.isPrimeButtonTapped) … counterViewStore.send(.primeModal(.saveFavoritePrimeTapped)) … counterViewStore.send(.primeModalDismissed) Type ‘CounterView.Action’ has no member ‘primeModal’
— 37:02
Now we’re seeing that one of our actions actually comes from the prime modal view store, so we’ll want to introduce one of them, as well: let primeModalViewStore = store .scope(value: { $0.primeModal }, action: { .primeModal($0) }) .view(removeDuplicates: ==) … primeModalViewStore.send(.saveFavoritePrimeTapped)
— 38:04
And now that we’re in building order, we can build and run our tests and they still pass, which can give us pretty good confidence that the actions we send through the view store are properly mapped to our expectations.
— 38:20
It is worth admitting that we’ve incurred a little bit of boilerplate along the way gain coverage of this glue code, but it’s also important to point out that our architecture is just as testable as ever! Next time: what’s the point?
— 38:31
The past few episodes have been quite long, and we’ve come a long way. We were first motivated by a simple performance problem, where naively making every store in our views an @ObservableObject we were accidentally rendering our views way too much. The solution was quite simple, we derived a new kind of object from our stores, called a view store, and it contained only the bits of state that the view actually cares about, thereby only rendering the view when something it depends on actually changes.
— 39:01
But we didn’t want to stop just there, because it turned out this view store abstraction helped us clean up a few rough edges in our views. In particular, it allowed us to move some of the logic in our views into a nice testable area, thereby making our views simpler and more testable, and it allowed us to describe more domain-specific state for our views without having to represent it in our application state.
— 39:22
But we didn’t want to just stop there either! We pushed the view store idea even further by making it responsible for sending actions, which gave us another opportunity to precisely mold the domain the view cares about. In particular, we could make our reducers a bit more agnostic to their use case and instead have views express domain-specific actions that are mapped to the reducer’s actions.
— 39:46
All of this together means our views are even more logicless, more direct, and easier to understand. But on Point-Free we like to always ask the question “what’s the point?” so that we can try to convince you that we are getting real benefits from these things and not just describing something that looks cool on the surface, but really isn’t that useful in practice.
— 40:06
And although it can be argued that are views are a bit simpler, it did come at a cost. We had to introduce an all new state struct and action enum for the view, we have to hold onto a whole new object in our views, and we have to provide transformations to construct the view stores. That seems like a lot of extra work to do for each of our views.
— 40:27
So, what’s the point? Is it worth doing this?
— 40:30
We of course think it is! To begin with, if your view is simple or if you don’t think there are any performance concerns in your app or for a particular screen, you can derive your view store by just hitting .view on your store and you’re done. No extra ceremony necessary!
— 40:58
However, once your application grows in size and use cases, you may find that the view store abstraction is exactly what you need to wrangle in complexity and unlock new capabilities. And to prove this, we are going to implement the feature that all of our PrimeTime users out there have been begging us for: a Mac app 😂.
— 41:17
OK, so there’s maybe not a huge demand out there for a Mac version of our wonderful little counting and prime calculator app, but it will help us demonstrate a key strength in using the view store. We can generalize our business logic so that it isn’t necessarily concerned with platform features, and then use view stores that adapt that generic functionality into a specific platform, like iOS, watchOS, macOS, tvOS or all 4. And to demonstrate this we are going to build a Mac version of our application that has some subtle differences from the iOS version that we have been building so far. In particular, right now when we ask if the current count is prime we show a modal, but on Mac we will show it as a popover because popovers on macOS are super lightweight and unfortunately are not supported on iPhones. We will also remove the double tap gesture for asking for the “nth prime”, as that type of gesture isn’t super common on Mac…next time! Downloads Sample code 0096-adaptive-state-management-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .