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:
- Part 1. Build your Pokédex: Introduction to NGRX
- Part 2. Build your Pokédex: @ngrx/entity
- Part 3. Build your Pokédex: Improve NgRX using create* functions
- Part 4. Build your Pokédex: @ngrx/data
- Part 5. Build your Pokédex: Testing NgRX
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:
- action type. Is the famous string used to identify the action.
- 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...
- Announing NgRx 8
- Angular Architecture Best Practices
- Angular Architecture - ng-conf
- Angular Architecture (official docs)
- NGRX
- RxJS
- Facade Pattern
The GitHub branch of this post is https://github.com/Caballerog/ngrx-pokedex/tree/ngrx-part3