EP 148 · Derived Behavior · May 31, 2021 ·Members

Video #148: Derived Behavior: Collections

smart_display

Loading stream…

Video #148: Derived Behavior: Collections

Episode: Video #148 Date: May 31, 2021 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep148-derived-behavior-collections

Episode thumbnail

Description

The Composable Architecture comes with several tools that aid in breaking large domains down into smaller ones, not just pullback and scope. This week we will see how it can take a small domain and embed it many times in a collection domain.

Video

Cloudflare Stream video ID: 286d4d9a1ef5d7498436528a2adef23e Local file: video_148_derived-behavior-collections.mp4 *(download with --video 148)*

Transcript

0:05

So, what we’ve seen is that the Composable Architecture comes with some tools out of the box that allow you to “derive behavior”, which means that you can take a big blob of behavior, such as the store that controls your entire application, and derive new stores that focus in on just a subset of that behavior. This is crucially important if you want to build large, complex applications that can be split apart into isolated modules.

0:28

Unfortunately SwiftUI does not give us these tools out of the box. Currently it is on us to manually implement the integration points between child view models and parent view models. We have to make sure that anytime the child changes we notify the parent so that it can do any work necessary, and we need to listen for changes to particular pieces of state in each child so that we can replay them to the other siblings. We also have to do extra work in order to make sure we don’t accidentally create infinite loops between children, such as when child A updates child B which causes child B to update child A, and so on.

1:06

So now that we’ve seen the benefits of scoping our domains to smaller and smaller units, let’s flex those muscles a bit more. Turns out there are lots of use cases for transforming stores beyond just simple scoping, and this starts to unlock even more ways you can break out small subdomains into their own little isolated worlds. We’re going to build a new demo app from scratch in order to explore this.

1:30

We’ll start with a project that already has a little bit of functionality implemented using the Composable Architecture, and it’s…yet another counter app. I’m sure our viewers are all sick of counter apps by this point, but we promise we will make it a lot more exciting. A counter feature

1:44

If we run the app in the preview we will see that already it’s got some interesting things going on. Once you increment or decrement to a number you like, you can tap the “Fact” button and that will fire off an API request to get a random fact about that number. For example, if we count up to 5 and then tap the “Fact” button we will learn that 5 is “the number of permanent members with veto power on the United Nations Security Council”.

2:13

Let’s quickly walk through the code that powers this feature. We can start with the domain, which consists of its state, actions and environment. The state is a struct holding the current count , along with an optional piece of state that determines if the fact’s alert is being shown or not: struct CounterState: Equatable { var alert: Alert? var count = 0 struct Alert: Equatable, Identifiable { var message: String var title: String var id: String { self.title + self.message } } }

2:46

The actions are specified by an enum that has a case for everything the user can do in the UI, along with all the actions effects want to send back into the system: enum CounterAction: Equatable { case decrementButtonTapped case dismissAlert case incrementButtonTapped case factButtonTapped case factResponse(Result<String, FactClient.Error>) }

3:05

The environment is a struct holding all the dependencies the feature needs to do its job. This includes something called a FactClient : struct CounterEnvironment { let fact: FactClient … }

3:13

which holds an endpoint that can be used to fetch a fact about a number: struct FactClient { var fetch: (Int) -> Effect<String, Error> struct Error: Swift.Error, Equatable {} }

3:26

And we have a live implementation of this dependency for hitting an actual network API for loading the data: extension FactClient { static let live = Self( fetch: { number in URLSession.shared.dataTaskPublisher( for: URL(string: "http://numbersapi.com/\(number)")! ) .map { data, _ in String(decoding: data, as: UTF8.self) } .mapError { _ in Error() } .eraseToEffect() } ) }

3:39

The environment also has a main queue scheduler so that we can re-dispatch any data from the API effect back to the main thread: struct CounterEnvironment { … let mainQueue: AnySchedulerOf<DispatchQueue> }

3:55

We consider dispatch queues “dependencies” because if we were to sprinkle uses of a “live” main dispatch queue throughout our feature, then in our tests we would have to wait little bits of time for scheduled work to be performed. By controlling this dependency, we can use an “immediate” or “test” scheduler in our tests and be very descriptive in how we expect time to flow in our application.

4:20

So that’s the domain of the feature.

4:22

Next we have a reducer defined, which implements the logic of the feature. Its job is to evolve the current state of the feature when an action comes in, and to return any effects that should be run after the action has been processed: let counterReducer = Reducer< CounterState, CounterAction, CounterEnvironment > { state, action, environment in switch action { case .decrementButtonTapped: state.count -= 1 return .none case .dismissAlert: state.alert = nil return .none case .incrementButtonTapped: state.count += 1 return .none case .factButtonTapped: return environment.fact.fetch(state.count) .receive(on: environment.mainQueue.animation()) .catchToEffect() .map(CounterAction.factResponse) case let .factResponse(.success(fact)): state.alert = .init(message: fact, title: "Fact") return .none case .factResponse(.failure): state.alert = .init(message: "Couldn't load fact.", title: "Error") return .none } }

5:00

And that’s the domain and the logic of the feature. With just this defined we can already go and write some tests. We could even write tests that exercise the effects being executed and assert how they feed data back into the system. We will write tests a little bit later in the episode, so for now we’ll move on.

5:19

The final piece to building the counter feature is the view. It is initialized with a Store , which is the runtime that powers features in the Composable Architecture, and then in the body it uses a WithViewStore view to observe changes to the feature’s state and to send user actions: struct CounterView: View { let store: Store<CounterState, CounterAction> var body: some View { WithViewStore(self.store) { viewStore in VStack { HStack { Button("-") { viewStore.send(.decrementButtonTapped) } Text("\(viewStore.count)") Button("+") { viewStore.send(.incrementButtonTapped) } } Button("Fact") { viewStore.send(.factButtonTapped) } } .alert(item: viewStore.binding( get: \.alert, send: .dismissAlert) ) { alert in Alert( title: Text(alert.title), message: Text(alert.message) ) } } } }

5:45

We also have a preview down at the bottom of the file, which shows what it takes to create the CounterView . We just have to supply a Store , and to initialize one of those we have to start it off with some initial state, the reducer that will power the runtime, and an environment of dependencies for the feature to use. For the purposes of the preview we can use the live fact client and a live main queue, but for tests we will probably want to use a mocked-out fact client and an immediate scheduler or a test scheduler. struct ContentView_Previews: PreviewProvider { static var previews: some View { NavigationView { CounterView( store: .init( initialState: .init(), reducer: counterReducer, environment: CounterEnvironment( fact: .live, mainQueue: .main ) ) ) } } }

6:27

So, in just about 100 lines of code we have a full standalone feature that could be dropped into its own module with no dependencies on other features. A counter row

6:39

But let’s kick things up a notch now. What if we wanted to have a list of these counter views, as well as the ability to add new counters to the list and remove existing ones. We’d love if we could leverage all the work we have here to keep the domain of each row nice and isolated from the rest of the app.

6:56

And luckily the Composable Architecture gives us the tools we need to accomplish this. We can embed a collection of the counter domain into a parent domain, and then use certain transformational operators on reducers and stores to split little bits of behavior from the main parent domain to hand down to each row of a list. This is extremely powerful, so let’s dive in.

7:19

We want to model the parent domain that contains the counter domain, but it’s too big of a step to go all the way up to the domain that holds a collection of the counter domain. There’s actually a smaller domain we can model first, which is a single row of the list. This domain has a few extra bits of information that are important for the row, but the counter feature doesn’t need to know anything about it.

7:42

For starters, each row needs to have a uniquely identifying piece of information because that’s how things like ForEach views work in SwiftUI. So, let’s create a struct to hold the state of the counter alongside an id : struct CounterRowState: Identifiable { var counter: CounterState let id: UUID }

8:10

Next, the actions for the row includes everything that is in the counter actions, but with the addition of an action for when the remove button is tapped on a row. We want to give the user the ability to remove a counter from the list, but that action doesn’t need to exist in the counter domain at all. So, let’s create an enum to hold all the actions from CounterAction as well as an additional action for when the remove button is tapped: enum CounterRowAction { case counter(CounterAction) case removeButtonTapped }

8:44

Now technically we can just stop right there for the domain. There’s no need to provide an environment because a row doesn’t need any dependencies that the counter feature doesn’t need. So we will just reuse the CounterEnvironment for the row. We also don’t need a reducer because the row won’t have any additional logic other than what happens inside the counter feature.

9:04

If someday in the future we start layering on more functionality in the row that is independent of the counter feature then we would introduce a proper CounterRowEnvironment and counterRowReducer , but for now let’s just keep things simple.

9:16

What’s nice about the little row domain we have defined is that this extra bit of data didn’t need to infect our counter domain at all. The counter feature doesn’t need to know about identifiers or remove buttons or any of that. And if in the future the row domain gets even more complicated that is just more information that we get to keep out of the counter domain.

9:35

Next we have the view for the row. It’s initialized with a store that holds onto only the domain of the counter row: struct CounterRowView: View { let store: Store<CounterRowState, CounterRowAction> }

9:55

The body of the view will consist of an HStack so that we can insert the counter view, which is constructed thanks to a scoping operation, along with a spacer to separate the counter from the remove button: var body: some View { HStack { CounterView( store: self.store.scope( state: \.counter, action: CounterRowAction.counter ) ) } }

10:53

To insert the remove button we need to construct a ViewStore because that’s the only way we get access to sending actions into the system. We typically construct ViewStore s using the WithViewStore helper view, and usually do that at the root of the view. But in this case the only part of the view that needs access to the ViewStore is the button, so we can localize to just that one part of the view: WithViewStore(self.store) { viewStore in Button("Remove") { viewStore.send(.removeButtonTapped, animation: .default) } }

11:53

Further, we don’t actually need access to any of the state inside the viewStore , and for situations like this we have a special operator on Store called .stateless that does a .scope under the hood to transform into a Void value: WithViewStore(self.store.stateless) { viewStore in Button("Remove") { viewStore.send(.removeButtonTapped, animation: .default) } }

12:08

That makes sure that we aren’t observing any state at all. If in the future we do need some state from the store we’ll just remove the .stateless and put in a proper .scope that plucks out just the state that it needs.

12:20

And finally let’s add a spacer view between the counter and remove button so that things align nicely. HStack { CounterView( store: self.store.scope( state: \.counter, action: CounterRowAction.counter ) ) Spacer() WithViewStore(self.store.stateless) { viewStore in Button("Remove") { viewStore.send(.removeButtonTapped, animation: .default) } } }

12:29

So we have now implemented a full feature for just the row of a list. We could even extract this out into own module if we wanted. Perhaps the easiest would be to extract out the counter row feature and counter feature into a single module, but if we were feeling really inspired we could even further split the row feature into its own separate module that depends on the counter feature. This would make sure that the row feature can be built in isolation without building the entire app, and that the counter feature can be built in isolation without being bogged down by the row feature. A counter list

13:01

We are now set up to start building a list of counters.

13:20

Just as we always do did before, we’ll start with the domain. The state needs to hold a collection of CounterRowState values so that we can display a list of counters to the user. There are two main ways to do this, each with their pros and cons.

13:30

We could of course start with just a simple array of CounterRowState s: struct AppState { var counters: [CounterRowState] }

13:40

This will get the job done for awhile, but it is really fraught. We need a way to uniquely identify a particular piece of counter state within the collection. For example, when a counter feature in a particular row fires off an effect, like say the fact API request, that effect will feed data back into the system, and we need some way to figure out which counter state is going to be affected by that action.

14:07

By using a simple array of values in AppState the only thing we can use to identify a particular counter is its position inside the array. But a piece of counter state can move around in this list as other counters are added and removed, which will cause its index in the array to change. This can cause actions to get sent to the wrong rows, or send actions to non-existent rows, which either means at best we will have subtle bugs or at worst we will have crashes.

14:37

Even vanilla SwiftUI deals with similar problems. This is one of the reasons why SwiftUI’s very own ForEach view requires the collection you hand it to hold values that conform to the Identifiable protocol.

14:50

Luckily the Composable Architecture comes with a collection type that behaves like an array, but you are able to look up elements in the array by an id, thus avoiding the pitfalls of trying to find a particular counter from its position in the array. This type is called IdentifiedArray and is generic over the type of the identifier and the type of element in the collection: struct AppState { var counters: IdentifiedArray<UUID, CounterRowState> }

15:22

In the case that the element of the collection is Identifable itself we can even use a slightly shorter type alias: struct AppState { var counters: IdentifiedArrayOf<CounterRowState> }

15:38

Next we can model the actions for the app. We can start simple by having a case for the user action of tapping on an “add” button, which should add a counter to the list: enum AppAction { case addButtonTapped }

15:54

We also want to model the actions for each counter in the list. This can be done by having a case that holds a CounterRowAction along with the id of the row that is sending the action: enum AppAction { case addButtonTapped case counterRow(id: UUID, action: CounterRowAction) }

16:15

So, when a .counterRow action is sent we will not only have the particular action from the row but also the

UUID 16:35

Next we need to model the environment, which will hold all the dependencies the counter feature needs, but we need an additional dependency. When a counter row is added we’re going to need some way to generate a

UUID 17:22

Next we need to construct the reducer that is going to run the app’s logic. It will be a reducer that operates on the app domain: let appReducer: Reducer<AppState, AppAction, AppEnvironment>

UUID 17:38

And it will probably be combined from a few pieces. We’ll want to run the counterReducer we defined above somehow on our collection of counter states, and we’ll probably want to mix in another reducer for handling app-specific logic, such as adding and remove counters: let appReducer: Reducer< AppState, AppAction, AppEnvironment > = .combine( )

UUID 18:05

Let’s start by getting the counterReducer in here. Currently the counterReducer operates on counter domain: let counterReducer: Reducer< CounterState, CounterAction, CounterEnvironment >

UUID 18:15

And we need it to operate on an identified array of counter row domain. There’s two levels of domain sitting above the counter domain: the row domain and the app domain.

UUID 18:30

The Reducer type comes with operators that allow us transform the super-local counterReducer into one that works on bigger and bigger domains. For example, we can use the .pullback operator to first transform the counterReducer into one that operates on the counter row domain. To do this we specify how to transform the parent domain into the child domain. So, we need to describe how to transform CounterRowState into CounterState via a key path, then how to transform CounterRowAction into CounterAction via a case path , which is our concept for adopting KeyPath -like functionality, but for enums, and then finally we need to transform the environment, but in this case both domains work with CounterEnvironment so there’s nothing to do there: counterReducer .pullback( state: \CounterRowState.counter, action: /CounterRowAction.counter, environment: { $0 } )

UUID 20:08

Now that we’re in the counter row domain we need to further transform it into the app domain. This is a little different than a standard .pullback because we want to run this child reducer on every element of a whole collection of states. To do this we use the .forEach operator on reducers, which looks similar to .pullback . We must describe how to transform AppState into the collection of row state, then how to transform AppAction s into a row action paired with an identifier, and finally how to construct a CounterEnvironment from a AppEnvironment : let appReducer: Reducer< AppState, AppAction, AppEnvironment > = .combine( counterReducer .pullback( state: \CounterRowState.counter, action: /CounterRowAction.counter, environment: { $0 } ) .forEach( state: \AppState.counters, action: /AppAction.counter(id:action:), environment: { CounterEnvironment(fact: $0.fact, mainQueue: $0.mainQueue) } ) )

UUID 21:36

Phew, that was a lot, but this is packing a huge punch. We’ve been able to keep the counter domain and counter row domains fully insulated from ever needing to know about the large domains they may be embedded in. Most crucially, the counter domain never needs to know anything about indices or identifiers of a collection it might be embedded in because it never needs to know that kind of information. It’s free to be embedded in an array, an identified array, a dictionary, or anything really, and the Reducer has the operators necessary to transform it into those domains.

UUID 22:19

Now, with the counterReducer pulled all the way back into the domain of the full app we can mix in an additional reducer to implement the rest of the application’s logic. For example, we need to implement the functionality so that when the “Add” button is tapped a counter is added to our collection: .init { state, action, environment in switch action { case .addButtonTapped: state.counters.append( .init(counter: .init(), id: environment.uuid()) ) return .none

UUID 23:29

We can also listen for the .removeButtonTapped action inside the .counterRow actions so that we can remove that counter from our collection: case let .counterRow(id: id, action: .removeButtonTapped): state.counters.remove(id: id) return .none

UUID 24:06

Everything else that happens in .counterRow actions we can just ignore since we don’t need to layer on any additional logic: case .counterRow: return .none

UUID 24:18

And now all that is left is the view. It will take a store of the full app domain to begin with: struct AppView: View { let store: Store<AppState, AppAction> var body: some View { } }

UUID 24:33

At the root of the view we can install a List for showing all the rows: var body: some View { List { } }

UUID 24:35

To provide data to this list you would typically use SwiftUI’s ForEach view, which allows you to provide a collection of data and then it will take care of creating a row for each element in that collection. To get access to the collection in our store we need to construct a ViewStore , and then we can use the ForEach view: var body: some View { WithViewStore(self.store) { viewStore in List { ForEach(viewStore.counters) { counter in } } } }

UUID 25:26

Unfortunately this closure hands us just a plain value of type CounterRowState , but what we want to construct in this closure is a CounterRowView , which takes a store: CounterRowView( store: <#Store<CounterRowState, CounterRowAction>#> )

UUID 25:48

We need some way of deriving stores of just counter row domain from our big store of all of app domain. Just as the Composable Architecture gives us tools for transforming reducers, it also gives us tools for transforming stores. There’s a view called ForEachStore which allows you to construct a store corresponding to each row of a list. If you first .scope the store to get it into the shape of a store that is focused only on the collection of counter state and counter actions, then you can use it in much the same way you would use ForEach : WithViewStore(self.store) { viewStore in List { // ForEach(viewStore.counters) { counter in ForEachStore( self.store.scope( state: \.counters, action: AppAction.counterRow(id:action:) ) ) { rowStore in } } }

UUID 26:44

But now the magical part is that rowStore is a store of counter row domain. This means we can now construct a CounterRowView : ForEachStore( self.store.scope( state: \.counters, action: AppAction.counterRow(id:action:) ) ) { rowStore in CounterRowView(store: rowStore) }

UUID 27:09

And we can now even shorten this by providing the CounterRowView initializer directly to the ForEachStore : ForEachStore( self.store.scope(state: \.counters, action: AppAction.counterRow(id:action:)), content: CounterRowView.init(store:) )

UUID 27:45

Now if we were to get a preview going of this we should see a list of counters, but unfortunately that list would be empty and there would be no way to add counters to the list. So, let’s add a navigation bar item for an “Add” button, and let’s go ahead and throw on a title for the screen while we’re at it: List { … } .navigationTitle("Counters") .navigationBarItems( trailing: Button("Add") { viewStore.send(.addButtonTapped) } )

UUID 28:14

If we get a preview into place we will see a fully-functioning application: struct ContentView_Previews: PreviewProvider { static var previews: some View { NavigationView { AppView( store: .init( initialState: .init(counters: []), reducer: appReducer, environment: AppEnvironment( fact: .live, mainQueue: .main, uuid: UUID.init ) ) ) } } }

UUID 29:00

While we can add a bunch of counters, each row of the list is intercepting taps and dispatching them to every button in a row. To opt out of this functionality, we need to change the row’s button style. HStack { … } .buttonStyle(PlainButtonStyle()

UUID 29:48

Each row is dispatching taps to individual buttons, and in fact we can tap “Remove” and get a nice animated removal. Adding looks abrupt by comparison, so let’s add an animation to that action. viewStore.send(.addButtonTapped, animation: .default)

UUID 30:17

And now counter rows animate in and out quite nicely.

UUID 30:20

Even better, each row is now a fully-functioning, independent counter. We can increment, decrement, and when we request a fact about a number we will see an alert, and so it seems to be doing everything we want.

UUID 31:13

So this is pretty amazing. In just a few steps we have been able to define the domain, logic and view for a particular feature, and then instantly transform it into something that works for lists that contain that feature in every single row. And all without making any changes to the child feature.

UUID 31:31

Further, the child feature, meaning the counter feature, is decently complex. It even involves a side effect for making an API request, and the output of that effect is fed back into the system and properly routed to the domain from which it originated. All of the index juggling and identifier search is completely hidden from us as long as we use the tools the Composable Architecture gives us to glue the domains together. Testing the entire domain

UUID 31:54

Even better, absolutely everything we have built here is 100% testable. That’s one of the amazing parts of the Composable Architecture. We build tools and operators for transforming our domains, but because those operators work on reducers it is all within the purview of our testing tools. This allows you to get even more guarantees that your domain and logic is hooked up like you expected.

UUID 32:18

To see this let’s write a quick test.

UUID 32:22

We’ll just write one test that exercises a bunch of parts of the feature at once. We can start with the basic scaffolding of the test: import ComposableArchitecture import XCTest @testable import DerivedDomain class DerivedDomainTests: XCTestCase { func testBasics() { } }

UUID 32:31

The first step to writing a test for a Composable Architecture feature is to create a test store. To do this we need to provide the initial state for the feature, the reducer we want to test, and the environment of dependencies that will be used for the test. The first two are easy enough to fill out: let store = TestStore( initialState: AppState(counters: []), reducer: appReducer, environment: AppEnvironment( fact: <#FactClient#>, mainQueue: <#AnySchedulerOf<DispatchQueue>#>, uuid: <#() -> UUID#> ) )

UUID 33:15

For the environment we want to plug in test-friendly versions of the dependencies rather than using live dependencies, which deal with the vagaries of external API services and scheduling work on background threads. For example, instead of providing the live FactClient we can construct an instance right here that simply returns a pre-determined fact: fact: FactClient(fetch: { .init(value: "\($0) is a good number.") })

UUID 33:47

For the main queue we can provide an immediate scheduler, which squashes all of time into a single instant so that we do not have to force the test suite wait for asynchronous work to be performed: mainQueue: .immediate

UUID 34:06

The .immediate scheduler is something we discussed at length in our series of episodes called Better Test Dependencies , and the code for the scheduler resides in our CombineSchedulers library that we open sourced about a year ago.

UUID 34:17

Finally we need to provide a

UUID 34:53

However, for our test we are just going to add a single counter, and so we can just force the

UUID 35:10

And I think we’ll need access to this actual uuid a few times in the test, so let’s extract it to its own variable: let id = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! … uuid: { id }

UUID 35:53

With the test store created we can start sending it some actions and asserting on how state evolves. We can start by tapping the “Add” button: store.send(.addButtonTapped) State change does not match expectation: AppState( counters: [ + CounterRowState( + counter: CounterState( + alert: nil, + count: 0 + ) + ) ] ) (Expected: −, Actual: +)

UUID 35:53

This fails because we didn’t make an assertion on how state changes when the add button is tapped, and we can see in this diff that a counter row was added, and we need to explicitly assert that this is the behavior we expect. To do so, we can open up a trailing closure where we can make mutations to $0 state. store.send(.addButtonTapped) { $0.counters = [ .init(counter: .init(), id: id) ] }

UUID 36:53

Now if we run tests this passes. The fact that this test passes means that we fully captured all of the mutation that happened in the application. If there was any other part of the application state that had changed we would get a failure.

UUID 37:06

The test passing also means that no effects were executed in an unexpected way, and we’ll see that in a moment.

UUID 37:12

Next we can assert what happens when the increment button is tapped on the counter row we just created. In order to send that action we need to specify the identifier for the row we want to interact with, and then we can specify the action we want to invoke: store.send(.counterRow(id: id, action: .counter(.incrementButtonTapped)))

UUID 37:53

If we run this test we will get a failure because we haven’t further asserted on how the state changes after sending this action. To do that we need to traverse into the counters identified array in AppState , pluck out the counter corresponding to the particular id we are interested in, and then increment that counter’s count: store.send(.counterRow(id: id, action: .counter(.incrementButtonTapped))) { $0.counters[id: id]?.counter.count = 1 }

UUID 38:25

And now the test is passing.

UUID 38:30

Let’s try testing something a little more complicated. Let’s tap the “Fact” button inside the row: store.send(.counterRow(id: id, action: .counter(.factButtonTapped)))

UUID 38:48

If we run tests they fail, and the error message tells us exactly what happened: The store received 1 unexpected action after this one: … Unhandled actions: [ AppAction.counterRow( id: 00000000-0000-0000-0000-000000000000, action: CounterRowAction.counter( CounterAction.factResponse( Result<String, Error>.success( "1 is a good number." ) ) ) ), ]

UUID 39:02

See, when the “Fact” button is tapped we fire off an effect to fetch a fact for the current count. That effect feeds its data back into the system, but we didn’t assert on how that happened in this test. The failure message is telling us that the test store received an action from an effect that we did not assert on, and therefore we need to.

UUID 39:20

To do this we can tell the test store that we expect to receive an action, and we further need to describe how the state changes after receiving it: store.receive( .counterRow( id: id, action: .counter( .factResponse(.success("1 is a good number.")) ) ) ) { $0.counters[id: id]?.counter.alert = .init( message: "1 is a good number.", title: "Fact" ) }

UUID 40:28

And now the test suite is back to passing.

UUID 40:33

We can send another action to dismiss the alert: store.send(.counterRow(id: id, action: .counter(.dismissAlert))) { $0.counters[id: id]?.counter.alert = nil }

UUID 40:55

And let’s finish off this test by sending one last action to remove the counter we added: store.send(.counterRow(id: id, action: .removeButtonTapped)) { $0.counters = [] } Next time: deriving behavior in optionals and enums

UUID 41:19

And just like that we have a pretty comprehensive test that exercises multiple parts of the application and even exercises how multiple domains interact with each other. The fact that we can execute logic in the domain of a single counter amongst an entire list of counters is kind of amazing. Just imagine trying to get this kind of testability and modularity with a vanilla SwiftUI view model. It’s hard to picture, but we will actually be taking a look at that soon enough.

UUID 41:49

However, before that we want to explore a few more tools for transforming domains. We just saw that the Composable Architecture comes with a tool for transforming a reducer on a local domain into one that works on an entire collection of that domain, along with a tool for transforming a store of a collection of domain into a store that focuses on just one single element of the collection.

UUID 42:13

That’s cool, but there are a lot of other data structures we may want to pick apart in that way. For example, what about optional state? Or more generally enum state? It’s possible to build tools similar to the .forEach higher-order reducer and ForEachStore view except that they work on optionals and enums instead of collections. To explore these concepts we are going to add a feature to our little toy application.

UUID 42:37

What if we didn’t want to show a simple alert when we got the fact from the API but instead wanted to show a banner at the bottom of the screen. And to make things a little more complex, the banner will itself have behavior of its own. We’ll add a button in the banner that allows you to fetch another, and it will even manage a little loading indicator that is displayed while the API request is in flight.

UUID 43:04

Let’s dig in…next time! Downloads Sample code 0148-derived-behavior-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 .