DEV Community

Joan Llenas Masó
Joan Llenas Masó

Posted on

Reusing ngrx reducers using higher-order functions

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 parametrized counterName (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

Top comments (3)

Collapse
 
alejojm profile image
Alejo Jm

hello this was awesome thanks... but when i publish the prod app i get this error:


Consider changing the function expression into an exported function.

the reducer:

export function reducerWithName(name: string) {
  return function reducer(state: State, action: ComponentActions): State {
    return ... do something using name 
  };
}

the module:

imports: [
     StoreModule.forFeature('user', fromReducer.reducerWithName('user')),
]

I'm unable to compile de prod project :-(
Thanks

Collapse
 
joanllenas profile image
Joan Llenas Masó

Have a look at this modified stackblitz example where I'm using InjectionToken to inject the reducers: stackblitz.com/edit/angular-3yguzm...
Interesting files are: reducers.ts and app.module.ts

Hope this fixes the issue.
Cheers!

Collapse
 
alejojm profile image
Alejo Jm

Yeah !!!! cheers awesome