Reusing ngrx reducers using higher-order functions
Joan Llenas Mas贸
Posted on July 22, 2018
Reducers are pure functions that specify how the application's state changes in response to actions sent to the store.
Usually, there's a one to one relationship between a store key and a reducer, but what happens when you want to use the same reducer logic in more than one place because, for example, you need various instances of the reducer output?
Higher-order functions
So, what are higher-order functions (HoF) anyway?
Functions that operate on other functions, either by taking them as arguments or by returning them, are called higher-order functions. (source)
There's nothing remarkable about that, but this statement is what makes such functions special:
Higher-order functions allow us to abstract over actions, not just values. (source)
Higher-order functions allow you to declare what stuff is instead of defining steps that change some state.
While a composition of higher-order functions carries semantic overload, a sequential manipulation of values carries a cognitive overload. You pick one.
HoF and reducers
A higher-order reducer is a function that returns a (configured) reducer.
Higher order reducers allow us to parametrize which actions are accepted by a reducer.
There are several strategies to achieve that. We'll take a look at a couple of them and see each one's pros and cons.
First of all, I encourage you to have a look at the basic (without higher-order reducers) canonical example that we're going to be using: the counter application. (stackblitz link)
Dynamically creating action names
Our first higher-order reducer will use a straightforward technique. We will configure the reducer by dynamically defining which action types accept.( stackblitz link )
Let's see how the modified counter.component.ts
looks like:
@Component({
selector: 'counter',
template: `
<button (click)="increment()">Increment</button>
<div>Current Count: {{ count$ | async }}</div>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset Counter</button>
`,
})
export class CounterComponent implements OnInit {
@Input() counterName: string;
count$: Observable<number>;
constructor(private store: Store<AppState>) {}
ngOnInit() {
this.count$ = this.store.pipe(select(this.counterName));
}
increment() {
this.store.dispatch({ type: `${this.counterName}.${INCREMENT}` });
}
decrement() {
this.store.dispatch({ type: `${this.counterName}.${DECREMENT}` });
}
reset() {
this.store.dispatch({ type: `${this.counterName}.${RESET}` });
}
}
The critical change here is the fact that our component now accepts a counterName
input, which will define the name of the store key that this component will be using as the data source and also what prefix the action type
will have.
And this is how we use the counter component in the app.component.html
template:
<counter [counterName]="'counter1'"></counter>
<hr>
<counter [counterName]="'counter2'"></counter>
Note that we have added two instances of the component. Each will be using its own dynamically created reducer.
Lets' see how the higher-order reducer looks like:
counter.reducer.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
export function counterReducer(counterName: string) {
const initialState = 0;
return function reducer(state: number = initialState, action: Action) {
switch (action.type) {
case `${counterName}.${INCREMENT}`:
return state + 1;
case `${counterName}.${DECREMENT}`:
return state - 1;
case `${counterName}.${RESET}`:
return 0;
default:
return state;
}
}
}
The higher-order reducer differs from the original implementation in three aspects:
- It returns the reducer function, not the new state.
- It wraps its state in the closure scope.
- The matched reducer action types are defined by prefixing the original action
type
with the parametrizedcounterName
(this is flexible, others prefer suffixes, you have to be consistent). There's not much more to say.
Last but not least, we need to change the Store
configuration. We need to add the store keys and use the higher-order reducer to assign their value.
app.module.ts
@NgModule({
imports: [
BrowserModule,
StoreModule.forRoot({
counter1: counterReducer('counter1'),
counter2: counterReducer('counter2')
})
],
declarations: [AppComponent, CounterComponent],
bootstrap: [AppComponent]
})
export class AppModule { }
And that's pretty much it.
The counterReducer('...')
function returns a new reducer each time it's called, and this reducer accepts action types in the form [actionName].[action.type]
.
Filtering actions by its payload
Very quickly, now that we understand the mechanism let's see another more type-safe strategy to implement higher-order reducers.
The philosophy is the same, but we'll be using action creators instead of dynamic action type names, and we'll also be adding more types to the table. (stackblitz link)
The differences between this project and the more dynamic one reside in just two files:
counter.reducer.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
// Action interface for higher-order reducers
interface CounterAction extends Action {
actionPrefix: string;
}
// Action creators
export class Increment implements CounterAction {
type = INCREMENT;
constructor(public actionPrefix: string) { }
}
export class Decrement implements CounterAction {
type = DECREMENT;
constructor(public actionPrefix: string) { }
}
export class Reset implements CounterAction {
type = RESET;
constructor(public actionPrefix: string) { }
}
export type CounterActions =
| Increment
| Decrement
| Reset;
// Reducer
export function counterReducer(actionPrefix: string) {
const initialState = 0;
function reducer(state: number = initialState, action: CounterActions) {
switch (action.type) {
case INCREMENT:
return state + 1;
case DECREMENT:
return state - 1;
case RESET:
return 0;
default:
return state;
}
}
return (state: number = initialState, action: CounterActions) => {
switch (action.actionPrefix) {
case actionPrefix:
return reducer(state, action);
default:
return state;
}
}
}
A lot has changed here. Fortunately, there aren't many changes in the other file.
counter.component.ts
@Component({
selector: 'counter',
template: `
<button (click)="increment()">Increment</button>
<div>Current Count: {{ count$ | async }}</div>
<button (click)="decrement()">Decrement</button>
<button (click)="reset()">Reset Counter</button>
`,
})
export class CounterComponent implements OnInit {
@Input() counterName: string;
count$: Observable<number>;
constructor(private store: Store<AppState>) {}
ngOnInit() {
this.count$ = this.store.pipe(select(this.counterName));
}
increment() {
this.store.dispatch(new Increment(this.counterName));
}
decrement() {
this.store.dispatch(new Decrement(this.counterName));
}
reset() {
this.store.dispatch(new Reset(this.counterName));
}
}
The main difference is that we have introduced the action creators, which satisfy the newly added CounterAction
interface and makes the actionPrefix
property mandatory for all its implementors.
Also, the CounterActions
type allows us to be much more specific about which actions the reducer accepts.
Pros and cons
Dynamically creating action names
-
Pros
- Very straightforward.
-
Cons
- It lacks type safety.
Filtering actions by its payload
-
Pros
- Typesafe.
-
Cons
- More verbose.
Some links
- Read the Eloquent Javascript book chapter about higher-order functions
- The Redux documentation has an excellent description of what a higher reducer is and does.
Posted on July 22, 2018
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.