Higher-Order Reducers with KeyPath and CaseKeyPath in the Composable Architecture

activesludge
AVIV Product & Tech Blog
9 min readMay 21, 2024

Expand your arsenal with this technique to take your app's capabilities one step further

A scene from the iconic movie Matrix (1999) where Neo was finally starts to feel he's the chosen one. This image visualises how I personally felt when I grasped the concept of KeyPath in Swift language for the first time.

Before we dive into the intricacies of reusable reducers, it’s important to note that this article is tailored for readers who already have a good understanding of the Composable Architecture (TCA). If you’re new to this framework/architecture, you may find it beneficial to familiarise yourself with it before proceeding.

When do we use the Higher-Order Reducers?

When we want to extract a common logic across actions, we create a private function and use that method. But, what about common logic across features?

My first intuition would be to create a dependency that has a name like “SomethingManager”, or “SomethingDoer” whose method takes the necessary arguments, then achives the desired objective, possibly returns a type. And that seems perfectly fine. But, I don't ever see myself overriding this dependency in testing, preview or any other environment, so a dependency feels like an overkill here. So, is there any other alternative?

By taking the inspiration from BindingReducer in TCA framework, I want to show you how you can create a Higher-Order Reducer that is sharable across any domain. This will take our reducer's capabilities one step further.

A real world case

An actual requirement urged us to find a solution with a custom Higher-Order Reducer. One of our form pages in our app has many fields where we need to validate each field with each keystroke and also with the submit action. We find the most optimal solution by intercepting the binding actions and a submit action that parent reducer uses, then finally do the validation and return error messages if needed. Now we are able to use this validation reducer in any other form that needs it!

What's the objective?

We are going to:

  • Create a reducer that can intercept certain actions, and mutate the parent's state,
  • make it so that the reducer is reusable in any other feature,
  • move any logic from the parent reducer into the Higher-Order Reducer that was otherwise bound to the feature.

In this article, we are also going to utilise KeyPaths from Swift and CaseKeyPaths from TCA.

In the end, the body inside our feature's reducer will look like this:

@Reducer
struct FeatureReducer {
...

var body: some ReducerOf<Self> {
...

HigherOrderReducer(
input: \.someProperty,
output: \.someOtherProperty,
triggerAction: \.someAction
)

...
}

...
}

If that's something you're interested, let's refresh our memory by covering some bases first.

KeyPaths

KeyPaths offer a flexible and type-safe mechanism for working with properties, whether accessing them directly, using them as parameters in functions.

To put it in simpler words, you define KeyPaths by saying “Here’s a key where I expect a property, from a certain type, that returns a certain type”. You can write onto or read from these properties.

\Root.someProperty, \.id , \.self , etc.

KeyPaths are already being used extensively by Swift and SwiftUI. You may remember them from ForEach view where you provide a KeyPath for id argument.

ForEach(
_ data: Data,
id: KeyPath<Data.Element, ID>,
content: @escaping (Data.Element) -> Content
)

Plus, this concept is gaining more popularity. It’s definitely worth mastering in order to advance in Swift language.

More information on that can be found below links:

CaseKeyPaths

Unfortunately, a structure like KeyPath did not exist for enum cases where we want pass actions which are exclusively enums. Not until, a new concept is introduced by TCA developers to address the missing part. It's called CaseKeyPath from CasePaths library by PointFree. It allows us to pass enum cases just like KeyPaths. It looks like this:

\.view.didTapButton

Delicious!

More information on CaseKeyPaths can be found below:

Fun fact

Here's what a CasePath looked like before the introduction of CaseKeyPath.

/Action.view.didTapButton

In order to pass a CasePath like the above the initialiser had to look like this:

init(toViewAction: @escaping (Action) -> ViewAction?) {
self.toViewAction = toViewAction
}

It was essentially a closure where it takes parent action as argument and expects to return another action. As of today, this is how the framework still works behind the counter. But it seems this usage is bound to be deprecated.

Higher-Order Reducer

In Swift, “higher order” typically refers to functions or closures that either accept other functions/closures as parameters, or return functions/closures as output. These higher-order functions allow for more flexibility and composability in code, as they can manipulate or encapsulate behaviour.

In earlier days (mid-2019), that was exactly what it looked like in TCA as well. It meant a reducer function that returns a reducer function.

func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {

return { state, action in
// do some computations with state and action
reducer(&state, action)
// inspect what happened to state?
}
}

Over years of transformation, in TCA context, it refers to reducers that are shareable, reusable, can handle an objective, and can be built on top of our apps. One of the most important example is the BindingReducer().

How would Higher-Order Reducers help us?

Managing complexity

In large applications, managing state and actions can get really complex. They help address this problem by allowing you to break down the state management logic into smaller, more manageable units. This makes it easier to understand, maintain, and modify the codebase as your application grows.

Reusability

They enable you to encapsulate common patterns or behaviours within reusable bodies. This promotes code reuse across different parts of your application, reducing duplication and ensuring consistency in how state is managed.

Testing

You can write unit tests for individual reducers. By this way we can ensure that each piece of state management logic behaves as expected in isolation.

Tutorial

Project Setup

Create a new project using Xcode. Minimum requirements for this project is as below:

  • Xcode 15.3
  • Swift 5.9
  • Minimum deployment target: iOS 17.0
  • TCA: 1.10.0

Once packages are resolved. Give it a run. You may be asked to enable macros by Xcode. Go ahead and enable them. Then we're good to go.

User story

I have come up with a user story for us to start exercising. It’s just a fictional one to keep things as simple as possible for us to step into the concept.

I want to be able to enter a text into a field. When I tap onto the button, I want to see that same text in uppercased form below. I want to be able to long press and copy the uppercased text. This logic has to be reusable. We’ll call this feature Shouting text.

The logic is to take a string as input and return an uppercased string as output after a certain trigger action.

Shouting text

First, create a reducer for our feature. Name it ShoutingText.swift . Copy and paste below code to this file.

import ComposableArchitecture

@Reducer
struct ShoutingText {
@ObservableState
struct State {
var text: String = ""
var uppercasedText: String = ""
}

enum Action: BindableAction {
case didTapShoutButton
case binding(BindingAction<State>)
}

var body: some ReducerOf<Self> {
BindingReducer()

Reduce { state, action in
switch action {
case .didTapShoutButton:
// Here's the state mutating logic that
// we want to move to the new reducer.
state.uppercasedText = state.text.uppercased()
return .none
case .binding:
return .none
}
}
}
}

Second, create a new file called ShoutingTextView.swift then copy and paste below code inside.

import ComposableArchitecture
import SwiftUI

struct ShoutingTextView: View {
@Bindable var store: StoreOf<ShoutingText>

var body: some View {
Form {
Section {
TextField(
"what do you mean",
text: $store.text
)
.textFieldStyle(.plain)

Button {
store.send(.didTapShoutButton)
} label: {
Image(systemName: "person.wave.2.fill")
}
}

Text(store.uppercasedText)
.textSelection(.enabled)
}
.navigationTitle("Shout it!")
}
}

#Preview {
NavigationStack {
ShoutingTextView(
store: Store(
initialState: ShoutingText.State(),
reducer: ShoutingText.init
)
)
}
}

When you run the preview you should see a screen like below.

"Shout it!" preview on canvas

Try and type something to the text field and tap onto the button below. See the text being uppercased.

If everything is in order, it means our base code is ready.

Creating a Higher-Order Reducer

These are not so different from typical feature reducers. They just don't have to possess a State struct and an Action enum.

Create a new file called UppercasedReducer.swift, then copy and paste below code inside.

import ComposableArchitecture

// 1
@Reducer
struct UppercasedReducer<State, Action, UppercaseAction> where Action: CasePathable {
// 2
private let keyPathToText: KeyPath<State, String>
private let keyPathToUppercasedText: WritableKeyPath<State, String>
private let caseKeyPathToAction: CaseKeyPath<Action, UppercaseAction>

init(
input keyPathToText: KeyPath<State, String>,
output keyPathToUppercasedText: WritableKeyPath<State, String>,
triggerAction caseKeyPathToAction: CaseKeyPath<Action, UppercaseAction>
) {
self.keyPathToText = keyPathToText
self.keyPathToUppercasedText = keyPathToUppercasedText
self.caseKeyPathToAction = caseKeyPathToAction
}

// 3
func reduce(into state: inout State, action: Action) -> Effect<Action> {
if action[case: caseKeyPathToAction] != nil {
state[keyPath: keyPathToUppercasedText] = state[keyPath: keyPathToText].uppercased()
}
return .none
}
}

Let's see what's happening here:

  1. UppercasedReducer is the Reducer that will do the job. Thanks to @Reducer macro, we're free from filling in some boilerplate code, such as conformances.
    Plus, it's generic. Which mean its properties will be inferred by the types of properties from whichever Reducer uses it when initialised.
    State is the parent state. Action is the parent action. UppercaseAction is the sub action of the parent action we want to intercept and mutate the state with.
    In order to take advantage of CaseKeyPaths, the Action generic parameter has to conform to CasePathable protocol.
  2. keyPathToText is a KeyPath to the text we want to turn into uppercase. It's read only. The root is the parent state and the value is a String.
    keyPathToUppercasedText is a WritableKeyPath to the final text we want to display on the screen. It's needs to be writable because we have to replace what's there after mutation. The root is the parent state and the value is a String.
    caseKeyPathToAcion is a CaseKeyPath to the action we want to intercept and make the state mutation. The root is the parent action and the value is an enum case of that action.
  3. Here's the reduce function that will do the mutation. We employ an if statement to determine if the action that is subscripted with the caseKeyPathToAction is not nil. This serves as our interception mechanism for actions.
    If the condition holds true, we proceed to replace the property referenced by keyPathToUppercasedText in the parent state with the property referenced by keyPathToText within the same parent, right after applying the .uppercased() method. Subsequently, we conclude the operation by returning the .none effect.

Usage of the Higher-Order Reducer

Return to ShoutingText.swift file and initialise the our new UppercasedReducer inside the body. Since we have no particular side effect to run, go ahead and remove the other Reduce initialiser.

import ComposableArchitecture

@Reducer
struct ShoutingText {
...

var body: some ReducerOf<Self> {
BindingReducer()

UppercasedReducer(
input: \.text,
output: \.uppercasedText,
triggerAction: \.didTapShoutButton
)
}
}

Thanks to @Reducer macro right above ShoutingText reducer, we don't have to explicitly conform the Action enum of the feature to CasePathable protocol. It's already covered! You can expand the macro to see conformances above there.

@CasePathable macro above Action enum provided by @Reducer macro.

And that’s it. Run the app and see the reducer is doing its job!

The end product hasn't changed a bit, but the underlying difference is huge. Now we have a reducer that is easily sharable. We won't have to repeat this logic anywhere in our code base. Lovely.

Congratulations on completing the above journey through the exercise using KeyPaths and CaseKeyPaths in the Composable Architecture!

By following along with this article, you’ve taken a solid step towards developing reusable reducers in your app.

On top of my mind, here are some examples where Higher-Order Reducer can be useful:

  • Logging
  • Tracking events
  • State property validations, filters, etc.

Let me know if you can think of any other use case!

Keep exploring, experimenting, and pushing the boundaries of what’s possible with the Composable Architecture.

Cheers!

--

--