Using enums to reduce boilerplate in ngrx

I really like @ngrx/store (especially combined with its companion, @ngrx/effects). Before I knew about it, my project team had been struggling to figure out a clean way to manage the data in a large Angular app. Fortunately, we attended a talk on Managing State in Angular 2 by Kyle Cordes, in which he walked through the different approaches to managing state (all state in the main component/splitting state between components/putting state in a service/bucket brigade/etc.) and how each of them fell short. We recognized each of those stages from our attempts to wrangle state in our app. He didn’t detail ngrx or the Redux pattern in that talk, but he pointed us in that direction.

When we started looking into the official ngrx example app as a guide, we liked a lot of what we saw, but we were also frustrated by the amount of boilerplate in the Actions and use of branching instead of polymorphism in the Reducer. I spoke with Rob Wormald at ng-conf 2017 about these concerns, and he said he knew that people didn’t like that about the pattern, but that nothing in ngrx required anyone to use those techniques; there just weren’t enough existing examples of alternatives.

In order to address these issues and provide the community with an alternative approach, I’ve released ngrx-example-app-enums to show how to reduce boilerplate with enums. I’ve also extracted the key files into ngrx-enums, a small library others could use to implement the pattern on top of ngrx.

Before

Let’s take a look at an actions file and a reducers file from the example app to see what motivated this work.

actions/book.ts:

import { Action } from '@ngrx/store';
import { Book } from '../models/book';

export const SEARCH =           '[Book] Search';
export const SEARCH_COMPLETE =  '[Book] Search Complete';
export const LOAD =             '[Book] Load';
export const SELECT =           '[Book] Select';


/**
 * Every action is comprised of at least a type and an optional
 * payload. Expressing actions as classes enables powerful
 * type checking in reducer functions.
 *
 * See Discriminated Unions: https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions
 */
export class SearchAction implements Action {
  readonly type = SEARCH;

  constructor(public payload: string) { }
}

export class SearchCompleteAction implements Action {
  readonly type = SEARCH_COMPLETE;

  constructor(public payload: Book[]) { }
}

export class LoadAction implements Action {
  readonly type = LOAD;

  constructor(public payload: Book) { }
}

export class SelectAction implements Action {
  readonly type = SELECT;

  constructor(public payload: string) { }
}

/**
 * Export a type alias of all actions in this action group
 * so that reducers can easily compose action types
 */
export type Actions
  = SearchAction
  | SearchCompleteAction
  | LoadAction
  | SelectAction;

This is not bad code, but I found it to be smelly:

  • Line 17: There’s nothing really wrong with this specific line, but there’s a lot of duplication between this class and the four classes that immediately follow it.
  • Line 20: I was really surprised when I realized that the payload property on Action was untyped. So, even though the type is specified in this constructor, the Action itself isn’t parameterized, so asserting type safety on the payload isn’t easy.
  • Lines 45-49: If I used this pattern, I would always forget to add these lines – I would prefer to define an action only once and not have to remember to add a reference to it somewhere else.

reducers/collection.ts:

import * as collection from '../actions/collection';

export interface State {
  loaded: boolean;
  loading: boolean;
  ids: string[];
};

const initialState: State = {
  loaded: false,
  loading: false,
  ids: []
};

export function reducer(state = initialState, action: collection.Actions): State {
  switch (action.type) {
    case collection.LOAD: {
      return Object.assign({}, state, {
        loading: true
      });
    }

    case collection.LOAD_SUCCESS: {
      const books = action.payload;

      return {
        loaded: true,
        loading: false,
        ids: books.map(book => book.id)
      };
    }

    case collection.ADD_BOOK_SUCCESS:
    case collection.REMOVE_BOOK_FAIL: {
      const book = action.payload;

      if (state.ids.indexOf(book.id) > -1) {
        return state;
      }

      return Object.assign({}, state, {
        ids: [ ...state.ids, book.id ]
      });
    }

    case collection.REMOVE_BOOK_SUCCESS:
    case collection.ADD_BOOK_FAIL: {
      const book = action.payload;

      return Object.assign({}, state, {
        ids: state.ids.filter(id => id !== book.id)
      });
    }

    default: {
      return state;
    }
  }
}

export const getLoaded = (state: State) => state.loaded;

export const getLoading = (state: State) => state.loading;

export const getIds = (state: State) => state.ids;

The reducer code doesn’t have as much duplication as the action code, but the switch statement that starts on line 16 smells. I would rather have classes that could share a method that is called polymorphically instead of having a big logical structure. Also, if we had each reducer implementation as a separate class, we could use generics to define the type of the payload, and testing should be easier.

What to Do Instead

To address these issues, I use a pattern with a parameterized superclass of Action (which I call TypedAction), and enum instances of the actions and reducers. I build the enums with ts-enums (GitHub/npm/blog post), which allows us to move the repeated code of the actions into a superclass and to replace the big switch statement with functional code that finds and calls the correct reducer class instance.

After

actions/book.ts:

import {Book} from '../models/book';
import {ActionEnum, ActionEnumValue} from './action-enum';

/**
 * Every action is comprised of at least a type and an optional
 * payload. Expressing actions as classes enables powerful
 * type checking in reducer functions. Enums simplify generating
 * the classes.
 */
export class BookAction<T> extends ActionEnumValue<T> {
  constructor(name: string) {
    super(name);
  }
}

export class BookActionEnumType extends ActionEnum<BookAction<any>> {

  SEARCH: BookAction<string> = new BookAction<string>('[Book] Search');
  SEARCH_COMPLETE: BookAction<Book[]> = new BookAction<Book[]>('[Book] Search Complete');
  LOAD: BookAction<Book> = new BookAction<Book>('[Book] Load');
  SELECT: BookAction<string> = new BookAction<string>('[Book] Select');

  constructor() {
    super();
    this.initEnum('bookActions');
  }
}

export const BookActionEnum: BookActionEnumType = new BookActionEnumType();

This is significantly less code with slightly more powerful semantics:

  • The code duplication is reduced now to instantiating an instance.
  • The type of the payload is now explicit (with void a legal option for contentless actions).
  • There is only one place to change to define each action.

There is some duplicated logic that is not apparent here; each enum type using ts-enums requires defining two types, in this case being BookAction and BookActionEnumType. BookActionEnumType holds and manages the instances of BookAction, and it would be nice not to have the duplication across enums defining two classes for each enum type.

reducers/collection.ts:

import {CollectionActionEnum} from '../actions/collection';
import {Book} from '../models/book';
import {ActionEnumValue, TypedAction} from '../actions/action-enum';
import {
  ReducerEnum,
  ReducerEnumValue,
  ReducerFunction
} from './reducer-enum';


export interface State {
  loaded: boolean;
  loading: boolean;
  ids: string[];
}

const initialState: State = {
  loaded: false,
  loading: false,
  ids: []
};

export class CollectionReducer<T> extends ReducerEnumValue<State, T> {
  constructor(action: ActionEnumValue<T> | ActionEnumValue<T>[],
              reduce: ReducerFunction<State, T>) {
    super(action, reduce);
  }
}

export class CollectionReducerEnumType extends ReducerEnum<CollectionReducer<any>, State> {

  LOAD: CollectionReducer<void> =
    new CollectionReducer<void>(CollectionActionEnum.LOAD,
      (state: State) => ({...state, loading: true}));
  LOAD_SUCCESS: CollectionReducer<Book[]> =
    new CollectionReducer<Book[]>(CollectionActionEnum.LOAD_SUCCESS,
      (state: State, action: TypedAction<Book[]>) => {
        return {
          loaded: true,
          loading: false,
          ids: action.payload.map((book: Book) => book.id)
        };
      });
  ADD_BOOK_SUCCESS: CollectionReducer<Book> =
    new CollectionReducer<Book>(
      [CollectionActionEnum.ADD_BOOK_SUCCESS,
        CollectionActionEnum.REMOVE_BOOK_FAIL],
      (state: State, action: TypedAction<Book>) => {
        const book = action.payload;

        if (state.ids.indexOf(book.id) > -1) {
          return state;
        }

        return Object.assign({}, state, {
          ids: [ ...state.ids, book.id ]
        });
      });
  REMOVE_BOOK_SUCCESS: CollectionReducer<Book> =
    new CollectionReducer<Book>(
      [CollectionActionEnum.REMOVE_BOOK_SUCCESS,
        CollectionActionEnum.ADD_BOOK_FAIL],
      (state: State, action: TypedAction<Book>) => {
        const book = action.payload;

        return Object.assign({}, state, {
          ids: state.ids.filter(id => id !== book.id)
        });
      });

  constructor() {
    super(initialState);
    this.initEnum('collectionReducers');
  }
}

export const CollectionReducerEnum: CollectionReducerEnumType = new CollectionReducerEnumType();

export const getLoaded = (state: State) => state.loaded;

export const getLoading = (state: State) => state.loading;

export const getIds = (state: State) => state.ids;

We don’t save as much code in the reducer definition as we do in the actions, but the big switch statement is gone. The superclasses ReducerEnum and ReducerEnumValue do a lot of heavy-lifting for us:

  • Accumulating an array of the reducers.
  • Ensuring that we do not have multiple reducer instances to handle the same action within the same file.
  • Allowing reducers to respond to multiple actions.
  • Lining up the types between the reducers and the action payload.
  • Assigning responsibility for reduction to the right reducer.

Using this Pattern

For the purposes of demonstrating the pattern, having TypedAction and the action and reducer enum superclasses be in the same project was useful, but it’s not useful for you to reuse the code. So, I also released ngrx-enums, a small library that extracts these utilities for general use. Call npm install --save ngrx-enums in your project, and replace the imports of action-enum and reducer-enum in these examples with importing from ngrx-enums.

Advertisements

About Lance Finney

Father of two boys, Java developer, Ethical Humanist, and world traveler (when I can sneak it in). Contributor to Grounded Parents.
This entry was posted in Programming and tagged , , , , . Bookmark the permalink.

One Response to Using enums to reduce boilerplate in ngrx

  1. Lance Finney says:

    Note: As of version 0.0.7, we’ve added a different pattern for defining reducers that is a lot less verbose than the ReducerEnum approach. Instead of using enums or a big switch statement on the type, we added matches() methods that enforce type safety and can be used in if-else logic.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s