Part 3. Build your Pokédex: Improve NgRX using create* functions

Carlos Caballero - Aug 19 '19 - - Dev Community

This post is part of a Series of post on which I'm describing how to build your Pokédex using NGRX from beginner to ninja, if you want to read more, you can read the following posts:


cover-1

Introduction

In this post, we will develop a pokédex using Angular framework and NgRX as a state management library. We will be using the new create* functions released with NgRX 8.

It is advisable to know how to manage Angular at an intermediate level and what a state management library is in order to understand this post properly. In this series, we will show how a specific example has been developed (Pokédex), which can be complement your NgRX learning.

First, the result of what will be built along these posts is shown in the following GIF.

It is essential to have read the first and second parts of this series of posts to be able to understand what is being built. In this post we will improve the code previously developed in the series by using the create* functions from the @ngrx/entity package, which will simplify the boilerplate code needed to create actions, reducers and effects.

createAction

In NgRX, a large amount of boilerplate code is needed when you want to create actions. You frequently need to create enums, action types, classes and union types. In this new version you can create actions in an easier way.

The NgRX core team have used the famous factory function design pattern to reach this goal. The factory function is createAction. The createAction function receives two parameters:

  1. action type. Is the famous string used to identify the action.
  2. props. Is the action metadata. For example, the payload.

To compare both, the below codes illustrates how you can use the new createAction function in our Pokédex.

before

export class LoadPokemon implements Action {
  readonly type = PokemonActionTypes.LOAD_POKEMONS;

  constructor() {}
}

export class LoadPokemonSuccess implements Action {
  readonly type = PokemonActionTypes.LOAD_POKEMONS_SUCCESS;

  constructor(public payload: Array<Pokemon>) {}
}

after

loadPokemonFailed = createAction(
   PokemonActionTypes.LOAD_POKEMONS_FAILED,
   props<{ message: string }>()
 ),

add: createAction(PokemonActionTypes.ADD, props<{ pokemon: Pokemon }>()),

In the before code you need to create a class which implements the Action interface, define the type attribute and the payload using the constructor. On the other hand, in the after code you only need to create the action using the createAction function, where the first parameter is the type and the second parameter is the props attribute (in our context, it will be payload).

Although the core team states that the use of an enum is not required, in my particular coding style, I prefer to define an action enum to be aware of the action set.

Therefore, the before and after pokemon.action.ts are the following ones:

import { Action } from '@ngrx/store';
import { Pokemon } from '@models/pokemon.interface';

export enum PokemonActionTypes {
  ADD = '[Pokemon] Add',
  ADD_SUCCESS = '[Pokemon] Add success',
  ADD_FAILED = '[Pokemon] Add failed',
  LOAD_POKEMONS = '[Pokemon] Load pokemon',
  LOAD_POKEMONS_SUCCESS = '[Pokemon] Load pokemon success',
  LOAD_POKEMONS_FAILED = '[Pokemon] Load pokemon failed',
  UPDATE = '[Pokemon] Update',
  UPDATE_SUCCESS = '[Pokemon] Update success',
  UPDATE_FAILED = '[Pokemon] Update failed',
  DELETE = '[Pokemon] Delete',
  DELETE_SUCCESS = '[Pokemon] Delete success',
  DELETE_FAILED = '[Pokemon] Delete failed'
}

export class LoadPokemon implements Action {
  readonly type = PokemonActionTypes.LOAD_POKEMONS;

  constructor() {}
}

export class LoadPokemonSuccess implements Action {
  readonly type = PokemonActionTypes.LOAD_POKEMONS_SUCCESS;

  constructor(public payload: Array<Pokemon>) {}
}
export class LoadPokemonFailed implements Action {
  readonly type = PokemonActionTypes.LOAD_POKEMONS_FAILED;

  constructor(public message: string) {}
}

export class Add implements Action {
  readonly type = PokemonActionTypes.ADD;

  constructor(public pokemon: Pokemon) {}
}

export class AddSuccess implements Action {
  readonly type = PokemonActionTypes.ADD_SUCCESS;

  constructor(public pokemon: Pokemon) {}
}
export class AddFailed implements Action {
  readonly type = PokemonActionTypes.ADD_FAILED;

  constructor(public message: string) {}
}

export class Delete implements Action {
  readonly type = PokemonActionTypes.DELETE;

  constructor(public id: number) {}
}
export class DeleteSuccess implements Action {
  readonly type = PokemonActionTypes.DELETE_SUCCESS;

  constructor(public id: number) {}
}
export class DeleteFailed implements Action {
  readonly type = PokemonActionTypes.DELETE_FAILED;

  constructor(public message: string) {}
}

export class Update implements Action {
  readonly type = PokemonActionTypes.UPDATE;

  constructor(public pokemon: Pokemon) {}
}
export class UpdateSuccess implements Action {
  readonly type = PokemonActionTypes.UPDATE_SUCCESS;

  constructor(public pokemon: Pokemon) {}
}
export class UpdateFailed implements Action {
  readonly type = PokemonActionTypes.UPDATE_FAILED;

  constructor(public message: string) {}
}

export type PokemonActions =
  | LoadPokemonSuccess
  | Add
  | AddSuccess
  | AddFailed
  | Delete
  | DeleteSuccess
  | DeleteFailed
  | Update
  | UpdateSuccess
  | UpdateFailed;
import { createAction, props } from '@ngrx/store';

import { Pokemon } from '@models/pokemon.interface';

export enum PokemonActionTypes {
  ADD = '[Pokemon] Add',
  ADD_SUCCESS = '[Pokemon] Add success',
  ADD_FAILED = '[Pokemon] Add failed',
  LOAD_POKEMONS = '[Pokemon] Load pokemon',
  LOAD_POKEMONS_SUCCESS = '[Pokemon] Load pokemon success',
  LOAD_POKEMONS_FAILED = '[Pokemon] Load pokemon failed',
  UPDATE = '[Pokemon] Update',
  UPDATE_SUCCESS = '[Pokemon] Update success',
  UPDATE_FAILED = '[Pokemon] Update failed',
  REMOVE = '[Pokemon] Delete',
  REMOVE_SUCCESS = '[Pokemon] Delete success',
  REMOVE_FAILED = '[Pokemon] Delete failed'
}

export const actions = {
  loadPokemon: createAction(PokemonActionTypes.LOAD_POKEMONS),
  loadPokemonSuccess: createAction(
    PokemonActionTypes.LOAD_POKEMONS_SUCCESS,
    props<{ pokemons: Pokemon[] }>()
  ),
  loadPokemonFailed: createAction(
    PokemonActionTypes.LOAD_POKEMONS_FAILED,
    props<{ message: string }>()
  ),
  add: createAction(PokemonActionTypes.ADD, props<{ pokemon: Pokemon }>()),
  addSuccess: createAction(
    PokemonActionTypes.ADD_SUCCESS,
    props<{ pokemon: Pokemon }>()
  ),
  addFailed: createAction(
    PokemonActionTypes.ADD_FAILED,
    props<{ message: string }>()
  ),
  remove: createAction(PokemonActionTypes.REMOVE, props<{ id: number }>()),
  removeSuccess: createAction(
    PokemonActionTypes.REMOVE_SUCCESS,
    props<{ id: number }>()
  ),
  removeFailed: createAction(
    PokemonActionTypes.REMOVE_FAILED,
    props<{ message: string }>()
  ),
  update: createAction(
    PokemonActionTypes.UPDATE,
    props<{ pokemon: Pokemon }>()
  ),
  updateSuccess: createAction(
    PokemonActionTypes.UPDATE_SUCCESS,
    props<{ pokemon: Pokemon }>()
  ),
  updateFailed: createAction(
    PokemonActionTypes.UPDATE_FAILED,
    props<{ message: string }>()
  )
};

I have exported an action const, which is a dictionary that contains the action name as key and the action itself as value.

createAction is a factory function that returns a function, called an ActionCreator, which returns an action object when called. Therefore, you have to invoke the ActionCreator when you want dispatch an action:

this.store.dispatch(addSuccess(pokemon: Pokemon));

It is no longer necessary to create an object associated with the class of the action, you can now invoke the function directly.

Because of this, the following refactoring must be applied to all the effects in which actions are created:

before

@Effect()
loadAllPokemon$: Observable<any> = this.actions$.pipe(
  ofType(PokemonActions.PokemonActionTypes.LOAD_POKEMONS),
  switchMap(() =>
    this.pokemonService.getAll().pipe(
    map(pokemons => new PokemonActions.LoadPokemonSuccess(pokemons)),
      catchError(message => of(new PokemonActions.LoadPokemonFailed(message)))
    )
  )
);

after

@Effect()
loadAllPokemon$: Observable<any> = this.actions$.pipe(
    ofType(PokemonActions.loadPokemon),
    switchMap(() =>
      this.pokemonService.getAll().pipe(
        map(pokemons => PokemonActions.loadPokemonSuccess({ pokemons })),
        catchError(message => of(PokemonActions.loadPokemonFailed({ message }))
          )
        )
      )
    )
  );

The effects themselves will be refactored in the next section using the createEffects function.

createEffects

NgRx 8 provides the createEffect method which is an alternative to the @Effect() decorator. The main advantage of using createEffect instead of the decorator is that it's type safe. That is, if the effect does not return an Observable<Action> it will throw compile errors.

In the following code fragment I will show you the loadAllPokemon$ effect before and after applying the new createEffect method. The migration is very easy.

before

@Effect()
loadAllPokemon$: Observable<any> = this.actions$.pipe(
    ofType(PokemonActions.loadPokemon),
    switchMap(() =>
      this.pokemonService.getAll().pipe(
        map(pokemons => PokemonActions.loadPokemonSuccess({ pokemons })),
        catchError(message => of(PokemonActions.loadPokemonFailed({ message }))
          )
        )
      )
    )
  );

after

loadAllPokemon$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PokemonActions.loadPokemon),
      switchMap(() =>
        this.pokemonService.getAll().pipe(
          map(pokemons => PokemonActions.loadPokemonSuccess({ pokemons })),
          catchError(message =>
            of(PokemonActions.loadPokemonFailed({ message }))
          )
        )
      )
    )
  );

Therefore, the before and after pokemon.effects.ts are the following ones:

before

import * as PokemonActions from '@states/pokemon/pokemon.actions';

import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';

import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { Pokemon } from '@shared/interfaces/pokemon.interface';
import { PokemonService } from '@services/pokemon.service';

@Injectable()
export class PokemonEffects {
  constructor(
    private actions$: Actions,
    private pokemonService: PokemonService,
    public snackBar: MatSnackBar
  ) {}

  POKEMON_ACTIONS_SUCCESS = [
    PokemonActions.PokemonActionTypes.ADD_SUCCESS,
    PokemonActions.PokemonActionTypes.UPDATE_SUCCESS,
    PokemonActions.PokemonActionTypes.DELETE_SUCCESS,
    PokemonActions.PokemonActionTypes.LOAD_POKEMONS_SUCCESS
  ];

  POKEMON_ACTIONS_FAILED = [
    PokemonActions.PokemonActionTypes.ADD_FAILED,
    PokemonActions.PokemonActionTypes.UPDATE_FAILED,
    PokemonActions.PokemonActionTypes.DELETE_FAILED,
    PokemonActions.PokemonActionTypes.LOAD_POKEMONS_FAILED
  ];

  @Effect()
  loadAllPokemon$: Observable<any> = this.actions$.pipe(
    ofType(PokemonActions.PokemonActionTypes.LOAD_POKEMONS),
    switchMap(() =>
      this.pokemonService.getAll().pipe(
        map(response => new PokemonActions.LoadPokemonSuccess(response)),
        catchError(error => of(new PokemonActions.LoadPokemonFailed(error)))
      )
    )
  );

  @Effect()
  addPokemon$: Observable<any> = this.actions$.pipe(
    ofType(PokemonActions.PokemonActionTypes.ADD),
    switchMap((action: any) =>
      this.pokemonService.add(action.pokemon).pipe(
        map((pokemon: Pokemon) => new PokemonActions.AddSuccess(pokemon)),
        catchError(error => of(new PokemonActions.AddFailed(error)))
      )
    )
  );

  @Effect()
  deletePokemon$: Observable<any> = this.actions$.pipe(
    ofType(PokemonActions.PokemonActionTypes.DELETE),
    switchMap(({ id }) =>
      this.pokemonService.delete(id).pipe(
        map(() => new PokemonActions.DeleteSuccess(id)),
        catchError(error => of(new PokemonActions.DeleteFailed(error)))
      )
    )
  );

  @Effect()
  updatePokemon$: Observable<any> = this.actions$.pipe(
    ofType(PokemonActions.PokemonActionTypes.UPDATE),
    switchMap(({ pokemon }) =>
      this.pokemonService.update(pokemon).pipe(
        map(() => new PokemonActions.UpdateSuccess(pokemon)),
        catchError(error => of(new PokemonActions.UpdateFailed(error)))
      )
    )
  );

  @Effect({ dispatch: false })
  successNotification$ = this.actions$.pipe(
    ofType(...this.POKEMON_ACTIONS_SUCCESS),
    tap(() =>
      this.snackBar.open('SUCCESS', 'Operation success', {
        duration: 2000
      })
    )
  );
  @Effect({ dispatch: false })
  failedNotification$ = this.actions$.pipe(
    ofType(...this.POKEMON_ACTIONS_FAILED),
    tap(() =>
      this.snackBar.open('FAILED', 'Operation failed', {
        duration: 2000
      })
    )
  );
}

after

import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, switchMap, tap } from 'rxjs/operators';

import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material';
import { Pokemon } from '@shared/interfaces/pokemon.interface';
import { actions as PokemonActions } from '@states/pokemon/pokemon.actions';
import { PokemonService } from '@services/pokemon.service';
import { of } from 'rxjs';

@Injectable()
export class PokemonEffects {
  constructor(
    private actions$: Actions,
    private pokemonService: PokemonService,
    public snackBar: MatSnackBar
  ) {}

  POKEMON_ACTIONS_SUCCESS = [
    PokemonActions.addSuccess,
    PokemonActions.updateSuccess,
    PokemonActions.removeSuccess,
    PokemonActions.loadPokemonSuccess
  ];

  POKEMON_ACTIONS_FAILED = [
    PokemonActions.addFailed,
    PokemonActions.updateFailed,
    PokemonActions.removeFailed,
    PokemonActions.loadPokemonFailed
  ];

  loadAllPokemon$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PokemonActions.loadPokemon),
      switchMap(() =>
        this.pokemonService.getAll().pipe(
          map(pokemons => PokemonActions.loadPokemonSuccess({ pokemons })),
          catchError(message =>
            of(PokemonActions.loadPokemonFailed({ message }))
          )
        )
      )
    )
  );

  addPokemon$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PokemonActions.add),
      switchMap((action: any) =>
        this.pokemonService.add(action.pokemon).pipe(
          map((pokemon: Pokemon) => PokemonActions.addSuccess({ pokemon })),
          catchError(message => of(PokemonActions.addFailed({ message })))
        )
      )
    )
  );

  deletePokemon$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PokemonActions.remove),
      switchMap(({ id }) =>
        this.pokemonService.delete(id).pipe(
          map(() => PokemonActions.removeSuccess({ id })),
          catchError(message => of(PokemonActions.removeFailed({ message })))
        )
      )
    )
  );

  updatePokemon$ = createEffect(() =>
    this.actions$.pipe(
      ofType(PokemonActions.update),
      switchMap(({ pokemon }) =>
        this.pokemonService.update(pokemon).pipe(
          map(() => PokemonActions.updateSuccess({ pokemon })),
          catchError(message => of(PokemonActions.updateFailed(message)))
        )
      )
    )
  );

  successNotification$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(...this.POKEMON_ACTIONS_SUCCESS),
        tap(() =>
          this.snackBar.open('SUCCESS', 'Operation success', {
            duration: 2000
          })
        )
      ),
    { dispatch: false }
  );

  failedNotification$ = createEffect(
    () =>
      this.actions$.pipe(
        ofType(...this.POKEMON_ACTIONS_FAILED),
        tap(() =>
          this.snackBar.open('FAILED', 'Operation failed', {
            duration: 2000
          })
        )
      ),
    { dispatch: false }
  );
}

Note that the previously used dispatch: false parameter passed to each effect is now the second parameter passed to the createEffect method. Remember that the option { dispatch: false } is used for effects that don't dispatch new Actions, adding this option also removes the restriction of effect having to return an Observable<Action>.

Reducers

The new createReducer method allows to create a reducer without a switch statement. There is a new on method to make a distinction between the action types, and it returns a new state reference. Another interesting fact is not needing to handle a default case for unhandled actions in the reducer.

Therefore, the before and after pokemon.reducers.ts are the following ones:

before

import { PokemonActionTypes, PokemonActions } from './pokemon.actions';
import { PokemonState, pokemonAdapter } from './pokemon.adapter';

export function pokemonInitialState(): PokemonState {
  return pokemonAdapter.getInitialState();
}

export function pokemonReducer(
  state: PokemonState = pokemonInitialState(),
  action: PokemonActions
): PokemonState {
  switch (action.type) {
    case PokemonActionTypes.LOAD_POKEMONS_SUCCESS:
      return pokemonAdapter.addAll(action.payload, state);

    case PokemonActionTypes.ADD_SUCCESS:
      return pokemonAdapter.addOne(action.pokemon, state);

    case PokemonActionTypes.DELETE_SUCCESS:
      return pokemonAdapter.removeOne(action.id, state);

    case PokemonActionTypes.UPDATE_SUCCESS:
      const { id } = action.pokemon;
      return pokemonAdapter.updateOne(
        {
          id,
          changes: action.pokemon
        },
        state
      );

    default:
      return state;
  }
}

after

import { Action, createReducer, on } from '@ngrx/store';
import { PokemonState, pokemonAdapter } from './pokemon.adapter';

import { actions as PokemonActions } from './pokemon.actions';

export function pokemonInitialState(): PokemonState {
  return pokemonAdapter.getInitialState();
}

const pokemonReducer = createReducer(
  pokemonInitialState(),
  on(PokemonActions.loadPokemonSuccess, (state, { pokemons }) =>
    pokemonAdapter.addAll(pokemons, state)
  ),
  on(PokemonActions.addSuccess, (state, { pokemon }) =>
    pokemonAdapter.addOne(pokemon, state)
  ),
  on(PokemonActions.removeSuccess, (state, { id }) =>
    pokemonAdapter.removeOne(id, state)
  ),
  on(PokemonActions.updateSuccess, (state, { pokemon }) =>
    pokemonAdapter.updateOne({ id: pokemon.id, changes: pokemon }, state)
  )
);

export function reducer(state: PokemonState | undefined, action: Action) {
  return pokemonReducer(state, action);
}

Note that the createReducer method receives a list of parameters:
the first parameter is the initial State and the second parameter is a list of on methods. In the on method the first parameter is the related action. In my case, I have maintained the actions enum because I like the data structure. You, of course, can export the actions directly without the use of an enum. The second parameter of the on method is a callback in which the state and payload are received. After that, we can use the powerful EntityAdapter to perform the most common operations.

Conclusions

In this post we have refactored our Pokédex by using the @ngrx/entity package's create* functions. The use of the create* functions will reduce unnecessary complexity in the management of the state of our application. Futhermore, the adapter is used to perform the most common operations (CRUD).

Therefore, in this post we have covered the following topics:

  • Automate the creation of state, since it is very repetitive, by using @ngrx/entity.
  • Automate the creation of effects, actions and simplify the reduce function by using @ngrx/entity.

The following posts in this series will cover interesting topics such as:

  • Facade pattern will be used through the @ngrx/data package.
  • Testing the application's state.

The most important part of this post is the concepts shown, and not the technique or library used. Therefore, this post should be taken as a guide for those who start to have large Angular applications and require applying architectural principles.

More, More and More...

The GitHub branch of this post is https://github.com/Caballerog/ngrx-pokedex/tree/ngrx-part3

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player