Leveraging custom Reducers to manage actions in your TCA app
As your TCA app grows, each feature accumulates more and more actions that it has to manage.
This could be UI actions, internal actions, child feature actions, delegate actions, etc.
enum Action {
case onAppear
case task
case buttonTapped
case closeButtonTapped
case downloadResponse(Result<UserInfo>)
case userProfile(UserProfile.State)
case favourites(Favourites.State)
case dismissFeature
}
After a while it becomes difficult to know which actions are which and we need a way to separate them. A widely used pattern is to create nested actions that hold onto only the actions relevant to it.
enum Action {
case view(ViewAction)
case internal(InternalAction)
case destination(PresentationAction<Destination>)
case delegate(DelegateAction)
enum ViewAction {
case onAppear
case task
case buttonTapped
case closeButtonTapped
}
enum InternalAction {
case downloadResponse(Result<UserInfo>)
}
enum Destination {
case userProfile(UserProfile.State)
case favourites(Favourites.State)
}
enum DelegateAction {
case dismissFeature
}
}
This gives us nice separation of the actions, but it makes it more difficult to organise the reducer body.
Reduce { state, action in
switch action {
case .view(.onAppear): ...
case .view(.task): ...
case .internal(.downloadResponse): ...
case .destination(.presented(.userProfile)): ...
case .delegate: ...
case .view: ...
}
}
It's very easy to get these in a muddle, lose exhaustivity, or duplicate logic unnecessarily.
But there is a way to make this much easier for yourself.
The Reducer type can be a bit daunting when you begin looking into how it works, but a lot of the custom reducers provided by the TCA package use a similar pattern. By looking at that and duplicating what we see, we can begin to put together an idea of how we might improve the situation here.
Wouldn't it be nice to create something that allowed us to spawn off a separate Reducer just for a particular sub-action?
Something like NestedAction(\.view) that would give us a body in which we just have to deal with the ViewAction actions without worrying about the others.
At first this seems similar to what Scope does, but in our case the problem is a bit more subtle. We're processing the ViewAction, but then we want to return an Effect that deals with the top-level Action enum. If we don't do this then we wouldn't be able to do something like this from the view action reducer:
return .send(.delegate(.dismissFeature))
Which is essential if we want our app to continue working. And also, we still want to be able to mutate the regular State without having to scope it.
So, we'll need something that is generic over State, Action, and ChildAction, and provides a way to scope into the child action.
This is what I came up with:
// 1.
@Reducer
public struct NestedAction<State, Action, ChildAction> {
// 2.
@usableFromInline
let toChildAction: AnyCasePath<Action, ChildAction>
// 3.
@usableFromInline
let toEffect: (inout State, ChildAction) -> Effect<Action>
@inlinable
public init(
_ toChildAction: CaseKeyPath<Action, ChildAction>,
toEffect: @escaping (inout State, ChildAction) -> Effect<Action>
) {
self.toChildAction = AnyCasePath(toChildAction)
self.toEffect = toEffect
}
public func reduce(into state: inout State, action: Action) -> Effect<Action> {
// 4.
guard let childAction = self.toChildAction.extract(from: action) else {
return .none
}
// 5.
return toEffect(&state, childAction)
}
}
Let's unwrap what it's doing:
- We define our
NestedActionreducer and make it generic overState,Action, andChildAction. - We have a property of a case path which tells us how to get from an
Actionto the relevantChildAction. - Finally, we have a closure which takes in
StateandChildActionand returns anEffect<Action>. This is the subtle difference between this and theReducereducer, as that will have a function fromState,ActiontoEffect<Action>. - In the body of our
NestedActionreducer we first check to see if the action coming in is one that we're interested in. We do this by trying to extract theChildActionfrom theActionusing the case path. If that doesn't work then we're not interested, so we just return a.noneeffect. - If the child action can be extracted then we pass it and the state into the closure and return any
Effectthat comes from that.
Now that we've created our new reducer, let's put it into use.
Let's deal with the view actions:
// We create a new NestedAction reducer just for the view action.
NestedAction(\.view) { state, action in
switch action {
case .onAppear:
state.text = "Hello, user!"
return .none
case .task:
return .none
case .buttonTapped:
return .run { send in
let userInfoResult = await clientDependency.downloadUserInfo()
await send(.internal(.downloadResponse(userInfoResult)))
}
case .closeButtonTapped:
return .send(.delegate(.dismissFeature))
}
}
For the other actions you can create separate NestedAction reducers to deal with them. The body of the reducer will look something like this. And using Xcode's folding ribbon it literally looks just like this:
var body: some ReducerOf<Self> {
NestedAction(\.view) { ... }
NestedAction(\.internal) { ... }
NestedAction(\.delegate) { ... }
NestedAction(\.destination) { ... }
}
Using this we could potentially mutate state in any of these reducers, but we still might want to use ifLet reducers or onChange reducers to add onto these. Especially with the onChange reducer, we don't know where a particular property might change, so which reducer do we add it to?
Thankfully TCA provides us with CombineReducers for free, which we can make use of here. So, adding an onChange reducer to the above might look like this:
var body: some ReducerOf<Self> {
CombineReducers {
NestedAction(\.view) { ... }
NestedAction(\.internal) { ... }
NestedAction(\.delegate) { ... }
NestedAction(\.destination) { ... }
}
.onChange(of: \.text) { oldValue, newValue in ... }
.ifLet(...) { ... }
}
This makes sure that wherever state is changed it will be caught in the onChange reducer. Similarly, it makes sure that the ifLet reducer is run before any of ours so that we can't invalidate the state in a way that breaks the app.
Reducers are really powerful in TCA, and by taking common problems and wrapping the logic into a new Reducer, we can really improve the look of our code and make it much more pleasurable to work with and maintain.
What new reducers have you created in your own TCA projects? I'd love to hear about them.