Video #203: Reducer Protocol: Composition, Part 1
Episode: Video #203 Date: Sep 5, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep203-reducer-protocol-composition-part-1

Description
We are already seeing huge benefits from the reducer protocol, but one aspect is still not ideal, and that is how we compose reducers. We will look to result builders to solve the problem, and a new feature of them introduced in Swift 5.7.
Video
Cloudflare Stream video ID: 7dc88d0423270bcd8aad3ec42d34c097 Local file: video_203_reducer-protocol-composition-part-1.mp4 *(download with --video 203)*
References
- Discussions
- SE-0348: buildPartialBlock for result builders
- 0203-reducer-protocol-pt3
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
The refactor to use the reducer protocol is complete, but the final code we ended up with is… well, let’s just say not ideal.
— 0:10
We naively converted some reducer operators to the brand new ReducerProtocol , such as combine , pullback , optional and forEach , but the nice thing about using a protocol is that we obtain a new tool to completely re-imagine reducer composition. And that’s result builders.
— 0:24
Result builders give us the opportunity to reimagine what the library’s core compositional operators look like. We can remove a lot of unnecessary noise when composing lots of reducers together, we can force more correctness guarantees into the API’s usage, and we can even work around some of the compiler limitations we mentioned previously, such as variadic generics.
— 0:46
Let’s revisit that particular limitation in order to find inspiration for why we want result builders and how we should implement them. Reducer builders
— 0:56
Recall that previously we decided against defining a top-level CombineReducers type that can be given a variadic list of reducers in order to combine them all into one big reducer: CombineReducers( Counter(), Counter(), Counter() )
— 1:11
We stated that this wasn’t possible to do because such a CombineReducers type would somehow need to be generic over every type of reducer we are combining, and we would need to have some kind of collection of all of those reducers: struct CombineReducers<<#???#>>: ReducerProtocol { let reducers: [<#???#>] }
— 1:23
Without variadic generics this is not possible, and so we would be forced to erase the types of the reducers we are combining, which causes us to lose the static types and a lot of the benefits we get from moving everything towards a reducer protocol.
— 1:40
So, without variadic generics, what are we to do?
— 1:43
We could certainly define a whole bunch of these types, one for each number of reducers we want to support: struct Combine3Reducers<R1, R2, R3>: ReducerProtocol { … } struct Combine4Reducers<R1, R2, R3, R4>: ReducerProtocol { … } struct Combine5Reducers<R1, R2, R3, R4, R5>: ReducerProtocol { … } …
— 2:11
However, using types like this would be a real pain. If we were combining 3 reducers: Combine4Reducers( Counter(), Counter(), Counter(), Counter() )
— 2:20
…and then decided to add a 5th, we’d not only need to add the extra reducer to the list, but also upgrade Combine4Reducers to be a Combine5Reducers : Combine5Reducers( Counter(), Counter(), Counter(), Counter(), Counter() )
— 2:29
Luckily there’s a better way. The problem of needing to build up large, complex types from a concise and fluent syntax has largely been solved in Swift, and the tool to do it is known as result builders.
— 2:42
Result builders allowed our parser library to abandon this clunky syntax for defining a parser that can turn a parenthesized pair of comma-separated values into a first-class User struct value: StartsWith("(") .take(Int.parser()) .skip(StartsWith(",")) .take(Prefix { $0 != ")") .skip(")") .map(User.init(id:name:))
— 3:04
…into a super succinct syntax that removes a lot of noise and leaves behind only the parsers we are composing: Parse(User.init(id:name:)) { "(" Int.parser() "," Prefix { $0 != ")" } ")" }
— 3:19
In particular, we no longer needed to specify when we want to skip or take the output of a parser. All of that is automatically handled for us behind the scenes. We just have to list out the parsers, and the builder will then run each parser, one after the other, and discard all the void values.
— 3:32
What if we could do something similar with reducers? What if we had a top-level entry point for entering builder syntax, and then inside there we could list out any number of reducers and behind the scenes they would just be combined in the usual way.
— 3:51
So, instead of CombineReducers being some type that is initialized with a concrete list of reducers, what if it acted as an entry point into listing out the reducers you want to combine: CombineReducers { Counter() Counter() Counter() }
— 3:56
Maybe this builder syntax will even allow for other new, cool ways to compose reducers, just as we saw with parsers.
— 4:04
So, let’s see what it takes to implement “reducer builders”. All result builders begin by defining a new type and annotating it with @resultBuilder . You can use any kind of type, including structs, enums, classes and even actors, but you cannot use protocols.
— 4:26
If the type you are defining doesn’t have any other purpose other than implementing the result builder’s requirements, then a good option is just a plain, empty enum: @resultBuilder public enum ReducerBuilder { }
— 4:38
This does not currently compile because Swift forces us to provide at least one build static method. Just to get things moving along we can quickly implement a buildBlock method that takes a single reducer and just turns right back around and returns it: public static func buildBlock<R: ReducerProtocol>( _ reducer: R ) -> R { reducer }
— 4:59
That is enough to make the compiler happy, and we can take it for a spin. We can implement the CombineReducers type as being an entry point into reducer builder syntax, which simply means it can be initialized with a trailing closure marked with @ReducerBuilder and returning some reducer: public struct CombineReducers< R: ReducerProtocol >: ReducerProtocol { let reducer: R public init(@ReducerBuilder build: () -> R) { self.reducer = build() } public func reduce( into state: inout R.State, action: R.Action ) -> Effect<R.Action, Never> { self.reducer.reduce(into: &state, action: action) } }
— 5:46
And with this type defined we can open up a trailing closure and specify a reducer inside: let reducer = CombineReducers { Counter() }
— 6:11
However, if we try listing another reducer: let reducer = CombineReducers { Counter() Counter() } Extra argument in call
— 6:16
We get a compiler error.
— 6:19
This is because we have only specified a single buildBlock method that only takes a single reducer. If we want to allow for listing more reducers in the build closure we need to implement more buildBlock static methods.
— 6:31
For example, we can support two by using the CombineReducer to combine two into one: public static func buildBlock< R0: ReducerProtocol, R1: ReducerProtocol >( _ r0: R0, _ r1: R1 ) -> CombineReducer<R0, R1> { CombineReducer(lhs: r0, rhs: r1) }
— 7:03
And now this compiles: let reducer = CombineReducers { Counter() Counter() }
— 7:07
However, as soon as we add a third it no longer compiles: let reducer = CombineReducers { Counter() Counter() Counter() } Extra argument in call
— 7:15
Looks like we have to add another buildBlock overload that takes 3 reducers, but interestingly we don’t need to introduce a Combine3Reducers like we theorized a moment ago. We can just nest the single CombineReducer : public static func buildBlock< R0: ReducerProtocol, R1: ReducerProtocol, R2: ReducerProtocol >( _ r0: R0, _ r1: R1, _ r2: R2 ) -> CombineReducer<R0, CombineReducer<R1, R2>> { .init(lhs: r0, rhs: .init(lhs: r1, rhs: r2)) }
— 7:55
And now this compiles: let reducer = CombineReducers { Counter() Counter() Counter() }
— 7:57
Sure this is pretty gnarly, but this just library code. The users of the library will never have to think about this nested type.
— 8:18
So, it does seem that reducer builders are going to actually give us an ergonomic improvement when trying to combine many reducers into one.
— 8:26
However, we do seem to be bounded by how many we can combine. We have to decide how many overloads of buildBlock we want to maintain, and then that will be the maximum number of reducers that can be combined at once. On the one hand it’s nice that the end-user doesn’t need to worry about things like Combine3 , Combine4 , and so on, but on the other hand there is a hidden limitation to how many reducers can be combined, and it will be frustrating when you run into it.
— 8:56
You can always work around this limitation by nesting builder closures. For example, we can already combine 9 reducers: let reducer = CombineReducers { CombineReducers { Counter() Counter() Counter() } CombineReducers { Counter() Counter() Counter() } CombineReducers { Counter() Counter() Counter() } }
— 9:12
However this is not ideal. It gets the job done, but of course it would be much nicer to just be able to list as many reducers as you want in a flat list.
— 9:20
For a long time we thought the only way this would be possible is if Swift got variadic generics, but thanks to a new feature of Swift 5.7, result builders alone are powerful enough to accomplish this.
— 9:31
Recall that result builders essentially give you access to various “build” static methods that correspond to various syntaxes used in Swift. The buildBlock methods represent stacking multiple statements on top of each other: a b c // buildBlock(a, b, c)
— 9:48
But there’s also buildOptional(_:) which represents an if statement: if condition { a } // buildOptional(a)
— 10:01
And there’s buildEither which represents branching if , else , and switch statements: if condition { a } else { b } // buildEither(first: a) // buildEither(second: b)
— 10:30
And there’s methods for supporting things like availability attributes, for loops, and more.
— 10:39
Swift 5.7 introduces a new type of “build” static method that can be used to dynamically combine any number of things into a single thing using an accumulation technique, and it’s called buildPartialBlock .
— 10:51
If you have a list of statements stacked on top of each other: a b c d
— 10:57
Then rather than all 4 values being passed to a single buildBlock overloaded with 4 arguments, a buildPartialBlock method is invoked for each statement, allowing you to accumulate the next statement in with the result of the previous statements.
— 11:11
The first line starts with a buildPartialBlock(first:) : a // buildPartialBlock(first: a)
— 11:19
Then it takes this first partially built block and combines it with the second: b // buildPartialBlock( // accumulated: buildPartialBlock(first: a), // next: b // )
— 11:33
Then it takes the second partially built block and combines it with the third: c // buildPartialBlock( // accumulated: buildPartialBlock( // accumulated: buildPartialBlock(first: a), // next: b // ), // next: c // )
— 11:45
And so on: d // buildPartialBlock( // accumulated: buildPartialBlock( // accumulated: buildPartialBlock( // accumulated: buildPartialBlock(first: a), // next: b // ), // next: c // ), // next: d // )
— 12:02
Notice how the buildPartialBlock s are nesting just like our CombineReducer s did when trying to define buildBlock for many arguments.
— 12:13
So, we can first define buildPartialBlock that takes a single argument, which represents the first statement in the list: public static func buildPartialBlock< R: ReducerProtocol >(first: R) -> R { first }
— 12:38
And then we can define another buildPartialBlock that takes two arguments: a particular statement in the list along with the accumulation of all the statements that preceded it: public static func buildPartialBlock< R0: ReducerProtocol, R1: ReducerProtocol >( accumulated: R0, next: R1 ) -> CombineReducer<R0, R1> { CombineReducer(lhs: accumulated, rhs: next) }
— 12:53
Just these two simple buildPartialBlock methods allow us to list out as many reducers as we want in a builder closure: let reducer = CombineReducers { Counter() Counter() Counter() Counter() Counter() Counter() Counter() Counter() Counter() … } We can even drop the previous buildBlock methods defined before.
— 13:09
It’s worth noting that the buildPartialBlock functionality for result builders is only available in Swift 5.7, and currently we are using the Xcode 14 beta to get access to 5.7. When we release the library we will need to do a little extra work to support those compiling with Swift 5.6 or less, but we won’t worry about that right now.
— 13:30
This is pretty incredible. Even without variadic generics we are allowed to combine as many reducers as we want, while still retaining all of the static type information.
— 13:39
Now, if variadic generics are ever introduced to Swift, there is a chance they could be slightly more efficient than what we are doing with buildPartialBlock . Partial blocks require us to deeply nest the CombineReducer type, as we can see by looking at this reducer type: let reducer: CombineReducers< CombineReducer< CombineReducer< CombineReducer< CombineReducer< CombineReducer< CombineReducer< CombineReducer< CombineReducer< Counter, Counter >, Counter >, Counter >, Counter >, Counter >, Counter >, Counter >, Counter > >
— 13:56
Swift can be quite good at inlining and optimizing away this nesting in optimized builds, but it’s also not perfect at doing that. Theoretically, by having variadic generics built into the language, there’s a chance we could express combining any number of reducers without building a deeply nested type like that, and that might provide a small performance win.
— 14:33
And already with this simple result builder defined we can already simplify two places we are currently combining multiple reducers into one. First we have the toy example we previously developed to explore an application reducer that combines the 3 reducers that power 3 tabs in a tab-based application: struct AppReducer: ReducerProtocol { … static let core = TabA() .pullback(state: \State.tabA, action: /Action.tabA) .combine( with: TabB() .pullback(state: \State.tabB, action: /Action.tabB) ) .combine( with: TabC() .pullback(state: \State.tabC, action: /Action.tabC) ) }
— 15:19
We can now flatten this a bit and remove the imbalance caused by having the 2nd and 3rd reducer indented an additional level: struct AppReducer: ReducerProtocol { … static let core = CombineReducers { TabA() .pullback(state: \State.tabA, action: /Action.tabA) TabB() .pullback(state: \State.tabB, action: /Action.tabB) TabC() .pullback(state: \State.tabC, action: /Action.tabC) } }
— 15:39
That’s looking pretty great.
— 15:48
We can also improve the gnarly VoiceMemos reducer we constructed last episode.
— 15:58
Recall that previously we made use of the combine(with:) operator to combine 3 reducers into one: the reducer that handles the logic for recording a new memo, the reducer that handles the logic for a row in the voice memos app such as playing back the memo, and the finally a reducer that layers on additional logic such as requesting recording permissions.
— 16:11
The reducer is intense, mostly due to the fact that the ergonomics of combine(with:) are not great. It gives undue prominence to the first reducer, and then later reducers are indented and surrounded by additional syntactic noise.
— 16:22
We can now flatten all of that inside a CombineReducers builder, and we don’t even need to user commas to separate the reducers. We do however need to introduce some explicit types for the pullback key path and the Reduce generics: func reduce( into state: inout State, action: Action ) -> Effect<Action, Never> { CombineReducers { RecordingMemo( audioRecorder: self.audioRecorder, mainRunLoop: self.mainRunLoop ) .optional() .pullback( state: \State.recordingMemo, action: /Action.recordingMemo ) VoiceMemo( audioPlayer: self.audioPlayer, mainRunLoop: self.mainRunLoop ) .forEach( state: \State.voiceMemos, action: /Action.voiceMemo(id:action:) ) Reduce<State, Action> { state, action in … } } .reduce(into: &state, action: action) }
— 17:00
It’s a little unfortunate that we have to specify more types in this reducer than we previously did. It seems that somehow the builder style has hindered type inference a bit. We will see that there is a way to recover this inference a little later.
— 17:13
It’s also still unfortunate we have to construct a big ole reducer just to then immediately hit its reduce method, but we will also have ways to simplify that later. Reducer builder operators
— 17:23
But now that we’ve got the basics of reducer builders in place we can start to re-imagine what our reducer operators could look like in this new world. Take the pullback operator as an example. Currently we have just naively ported it from the old-style to the new protocol-style as a method. The only substantial change made to the operation is that we no longer have to transformation environments since reducers no longer even have environments.
— 17:47
But let’s see what happens if we try to envision all new ways of expressing the pullback operator using reducer builders.
— 17:55
Take the tab-based reducer we just flattened using a builder block: struct AppReducer: ReducerProtocol { … static let core = CombineReducers { TabA() .pullback(state: \State.tabA, action: /Action.tabA) TabB() .pullback(state: \State.tabB, action: /Action.tabB) TabC() .pullback(state: \State.tabC, action: /Action.tabC) } }
— 18:05
Two episodes back we had theorized a new formulation of pullback in the result builder style, which we called Scope . Rather than thinking of scope as an operation performed directly on a child reducer in order to fit into a parent reducer, we instead think of it as an operation on the parent domain the chisels down to just the child domain, and then provides a builder context for the child reducer to work in that domain.
— 18:32
It would essentially turn our tab-based application reducer it something like this: struct AppReducer: ReducerProtocol { … static let core = CombineReducers { Scope(state: \State.tabA, action: /Action.tabA) { TabA() } Scope(state: \State.tabB, action: /Action.tabB) { TabB() } Scope(state: \State.tabC, action: /Action.tabC) { TabC() } } }
— 18:57
Also as we mentioned in the previous episode, this seemingly innocent flip of concepts also gives the compiler a little bit more information to help it out with type inference and autocomplete. We will see this concretely in a moment.
— 19:08
Let’s see what it takes to implement this Scope reducer. We can actually leverage a lot of the work we have already done for the pullback operator. We’ll rename the PullbackReducer type to Scope : public struct Scope< ParentState, ParentAction, Child: ReducerProtocol >: ReducerProtocol { … }
— 19:24
We’ll add a public initializer that takes the child reducer as a builder closure: public init( state toChildState: WritableKeyPath<ParentState, Child.State>, action toChildAction: CasePath<ParentAction, Child.Action>, @ReducerBuilder _ child: () -> Child ) { self.toChildState = toChildState self.toChildAction = toChildAction self.child = child() }
— 19:53
We’ll fix the pullback operator to deal with this new initializer, though really we shouldn’t be using this operator anywhere and instead prefer the builder-style scoping: Scope(state: toChildState, action: toChildAction) { self }
— 20:22
And with those few changes the app reducer that scopes to 3 different child domains is now compiling: struct AppReducer: ReducerProtocol { … static let core = CombineReducers { Scope(state: \State.tabA, action: /Action.tabA) { TabA() } Scope(state: \State.tabB, action: /Action.tabB) { TabB() } Scope(state: \State.tabC, action: /Action.tabC) { TabC() } } }
— 20:32
And we can layer on additional logic to be run along side the tabs’ logic by inserting another reducer before or after all of the scopes. We can do so with the Reduce type we defined earlier, which allows us to quickly construct an ad hoc reducer using a simple closure: struct AppReducer: ReducerProtocol { … static let core = CombineReducers { Reduce<State, Action> { _, _ in // Additional logic run before all the tabs .none } Scope(state: \State.tabA, action: /Action.tabA) { TabA() } Scope(state: \State.tabB, action: /Action.tabB) { TabB() } Scope(state: \State.tabC, action: /Action.tabC) { TabC() } Reduce<State, Action> { _, _ in // Additional logic run after all the tabs .none } } }
— 20:57
We can even apply the Scope reducer to our voice memos demo in the part where we transform the RecordingMemo reducer to the domain of the root reducer: Scope( state: \State.recordingMemo, action: /Action.recordingMemo ) { RecordingMemo( audioRecorder: self.audioRecorder, mainRunLoop: self.mainRunLoop ) .optional() }
— 21:15
This works, but we can do better.
— 21:17
As we mentioned in our first episode of this series, the optional reducer operator requires special care when using. The order we combine this optionalized reducer with the core reducer is very important. If we did something seemingly innocuous, such as moving the core reducer up to the top: func reduce( into state: inout State, action: Action ) -> Effect<Action, Never> { CombineReducers { Reduce<State, Action> { state, action in … } Scope( state: \State.recordingMemo, action: /Action.recordingMemo ) { RecordingMemo( audioRecorder: self.audioRecorder, mainRunLoop: self.mainRunLoop ) .optional() } VoiceMemo( audioPlayer: self.audioPlayer, mainRunLoop: self.mainRunLoop ) .forEach( state: \State.voiceMemos, action: /Action.voiceMemo(id:action:) ) } .reduce(into: &state, action: action) }
— 21:32
…we have unwittingly introduced a potential bug to our application, and we wouldn’t know about it until we encounter a runtime warning while running the app in the simulator or device, or if you have carefully read the documentation for the optional operator.
— 21:45
The problem is that with the current order we have the possibility that a recording action comes into the system, the core feature reducer sees it and decides to nil out the recording memo state, which in turn means the recording memo reducer doesn’t get a chance to react to it. That can cause subtle bugs that are hard to catch, and that’s why we display loud, runtime warnings when an action is sent to an optional reducer while the state is nil .
— 22:14
It would be far better if we could somehow design the API so that we could enforce the combining order of these reducers without the user ever having to think about it. This is possible, and reducer builders give us the perfect time to explore this new kind of API.
— 22:26
Currently the optional operation works directly on the child domain and has no knowledge of the parent domain. We have to manually combine it with the parent domain via the Scope and CombineReducers .
— 22:38
What if instead we had a method that operated on the parent domain, and then you specified the transformations that identify the optional sub-domain you want to run a reducer on, and then finally you specify the reducer to run via a builder closure: Reduce<State, Action> { state, action in … } .ifLet( state: \State.recordingMemo, action: /Action.recordingMemo ) { RecordingMemo( audioRecorder: self.audioRecorder, mainRunLoop: self.mainRunLoop ) }
— 23:07
This would give the operation knowledge of both the parent and child domains so that it could enforce the order. This would be a huge win.
— 23:22
So, let’s see what it would take to implement this ifLet operator. Like all other operators it begins with an extension to the ReducerProtocol to define a method that returns some type that we don’t have yet: extension ReducerProtocol { public func ifLet() -> <#???#> { } }
— 23:43
This method needs to take some arguments to do its job. It needs the transformations for turning parent domain into child domain, and it needs the child reducer to run on the child domain when it’s non- nil : extension ReducerProtocol { public func ifLet<Child: ReducerProtocol>( state toChildState: WritableKeyPath<State, Child.State?>, action toChildAction: CasePath<Action, Child.Action>, @ReducerBuilder then child: () -> Child ) -> <#???#> { } }
— 24:24
Now we have to define the type that will be returned from this operator: public struct IfLetReducer: ReducerProtocol { }
— 24:34
It needs to be generic over the parent and child reducers that we are operating on: public struct IfLetReducer< Parent: ReducerProtocol, Child: ReducerProtocol >: ReducerProtocol { }
— 24:49
And it needs to hold onto all of this data in order to do its job: public struct IfLetReducer<…>: ReducerProtocol { let parent: Parent let child: Child let toChildState: WritableKeyPath<Parent.State, Child.State?> let toChildAction: CasePath<Action, Child.Action> }
— 25:03
Its job is to first run the child reducer if the state is not nil , and emit a runtime warning if a child action is sent while the state is nil , and then run the parent reducer: public struct IfLetReducer<…>: ReducerProtocol { … public func reduce( into state: inout Parent.State, action: Parent.Action ) -> Effect<Parent.Action, Never> { CombineReducers { Scope( state: self.toChildState, action: self.toChildAction ) { self.child.optional() } self.parent } .reduce(into: &state, action: action) } }
— 26:04
That completes the definition of the IfLetReducer , which we can now use to return from the ifLet method: extension ReducerProtocol { public func ifLet<Child: ReducerProtocol>( state toChildState: WritableKeyPath<State, Child.State?>, action toChildAction: CasePath<Action, Child.Action>, @ReducerBuilder then child: () -> Child ) -> IfLetReducer<Self, Child> { .init( upstream: self, child: child(), toChildState: toChildState, toChildAction: toChildAction ) } }
— 26:22
With that implemented the theoretical syntax in the VoiceMemos reducer is now compiling, which means we can remove the scoped optional reducer code, and this code is now more correct.
— 26:42
We can do something similar with the .forEach operator because it too has a subtle ordering requirement. The child reducer that we apply forEach to must be run before the parent reducer is run. Otherwise, there’s the possibility that the parent reducer will remove an element from the collection when a child action is sent, causing the child reducer to miss out on reacting to that action.
— 27:06
The fix is to repeat what we did for ifLet . Rather than forEach being an operator defined on the child domain in order to transform it into the parent domain, we can make it operate on the parent domain by providing the transformations that identify the collection of child domain along with a builder closure for specifying the child reducer to run on the collection’s elements: Reduce<State, Action> { state, action in … } .ifLet( state: \State.recordingMemo, action: /Action.recordingMemo ) { RecordingMemo( audioRecorder: self.audioRecorder, mainRunLoop: self.mainRunLoop ) } .forEach( state: \State.voiceMemos, action: /Action.voiceMemo(id:action:) ) { VoiceMemo( audioPlayer: self.audioPlayer, mainRunLoop: self.mainRunLoop ) }
— 27:50
This makes reducing over collections look almost identical to reducing over optionals, which would be pretty amazing. It also just reads better, because we are now in some sense “forEach”-ing over the parent domain, which holds onto the collection, rather than “forEach”-ing on a child domain in order to lift it up to the parent domain.
— 28:07
Now technically we already a forEach operator defined on the reducer protocol, which we are using. But we now know we want to move away from this style, so let’s delete it and start over from scratch.
— 28:18
Defining the correct forEach operator is quite similar to what we did for ifLet , so we are just going to paste in the final answer: extension ReducerProtocol { public func forEach< ID: Hashable, Element: ReducerProtocol >( state toElementsState: WritableKeyPath< State, IdentifiedArray<ID, Element.State> >, action toElementAction: CasePath< Action, (ID, Element.Action) >, @ReducerBuilder _ element: () -> Element ) -> ForEachReducer<Self, ID, Element> { .init( parent: self, toElementsState: toElementsState, toElementAction: toElementAction, element: element() ) } } public struct ForEachReducer< Parent: ReducerProtocol, ID: Hashable, Element: ReducerProtocol >: ReducerProtocol { public let parent: Parent public let toElementsState: WritableKeyPath< Parent.State, IdentifiedArray<ID, Element.State> > public let toElementAction: CasePath< Action, (ID, Element.Action) > public let element: Element public func reduce( into state: inout Parent.State, action: Parent.Action ) -> Effect<Parent.Action, Never> { return .merge( self.reduceForEach(into: &state, action: action), self.parent.reduce(into: &state, action: action) ) } func reduceForEach( into state: inout Parent.State, action: Parent.Action ) -> Effect<Parent.Action, Never> { guard let (id, elementAction) = self.toElementAction .extract(from: action) else { return .none } if state[keyPath: self.toElementsState][id: id] == nil { // TODO: Update language runtimeWarning( """ A "forEach" reducer received an action when state \ contained no element with that id. """ ) return .none } return self.element .reduce( into: &state[ keyPath: self.toElementsState ][id: id]!, action: elementAction ) .map { self.toElementAction.embed((id, $0)) } } }
— 28:40
With this we are able to delete the old style of performing forEach , and now everything compiles, even our theoretical forEach that was appended after the ifLet .
— 28:45
This has massively simplified the construction of this complex reducer and made it safer and more correct. In fact, we are no longer combining multiple reducers together via the CombineReducers top-level type, instead it’s all being performed inside the forEach and ifLet operators. So we can remove that wrapper and remove a layer of indentation. Next time: reducer bodies
— 29:04
We’ve now got a ReducerProtocol in place along with a ReducerBuilder , and things are looking really great. We see that we can still accomplish everything we were doing in the old style, but we are getting all new tools for simplifying and organizing our features built in the library.
— 29:18
But there’s still something not quite right with our VoiceMemos reducer. It is really strange that in order to implement its reduce method we are constructing a big, composed reducer just to turn around it hit its reduce method to actually perform the work. The whole reason we are doing this strange dance is because we are trying to move away from defining our reducers as variables defined at the file-scope, which is an admirable goal since file-scope variables seem to put strain on the compiler. But it still feels like we haven’t quite cracked the ergonomics of this pattern yet.
— 29:51
It feels like there’s actually two styles of implementing reducers. First, there are the leaf node features, such as the RecordingMemo and VoiceMemo features. In those situations we aren’t composing together and integrating multiple domains. We are just implementing the bare logic for those features in a reduce method.
— 30:07
But then there are also the more complicated features that do need to integrate multiple domains together, and in those situations we prefer to compose things together using result builder syntax.
— 30:18
Amazingly, it’s possible to augment the ReducerProtocol so that we can support both of these styles in one package. Then, at the moment of conforming a type to the ReducerProtocol , we can decide which style do we want to implement. Are we implementing some simple standalone logic, or are we composing many things together?
— 30:38
Let’s see what it takes to make this happen…next time! References SE-0348: buildPartialBlock for result builders Richard Wei • Mar 22, 2022 The Swift Evolution proposal that introduced buildPartialBlock to result builders, making it possible to accumulate a result from many components. This works around the common limitation of having to define a number of buildBlock overloads to support larger builders. https://github.com/apple/swift-evolution/blob/4f0726385513577f25a2533f1863af4d6093e61a/proposals/0348-buildpartialblock.md Downloads Sample code 0203-reducer-protocol-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 .