
29 avr. 2019
Présentation
NGRX est un groupe de librairies inspirées par le pattern Redux, qui est lui-même inspiré du pattern Flux.
Le pattern ngrx est un pattern de gestion d'état. Il stocke l'état de l'application (State) dans un Store.
Il repose sur les 3 principes de Redux:
Single source of truth : Le State est stocké dans un Store unique.
State readonly : Immutabilité du State. Une fois créé, la seule façon de le modifier est de le recréer.
Changes are made with pure functions : Permet d'éviter les effets de bords.
Les applications Angular classiques ont un State divisé et pris en charge dans plusieurs services. Plus l'application grandit, plus on a de difficultés à garder une trace des changements de State et la maintenance et le debug peuvent être chaotiques. Le principe de Single source of truth résoud ce problème car le State est géré uniquement par un seul objet et à un seul endroit. Débugger et ajouter / modifier devient donc plus simple.
Le State est modifié à un seul endroit et uniquement en réponse à des actions précises. Encore une fois, cela rend l'application plus facile à débugger et à tester.
Le flow
Le Component qui à besoin d'afficher, modifier ou supprimer une donnée du Store va propager (dispatcher) une Action.
Les Action sont des classes qui implémentent l'interface Action (@ngrx/store). Ces classes ont deux propriétés :
type
: qui décrit l'actionpayload
: le type de cette propriété dépend du type de données que l'action a besoin d'envoyer au reducer.
Si cette Action n'est pas suivie d'un appel à un service, alors un Reducer va l'analyser grâce à un switch-case et retourner un nouveau State. (Voir 3.1)
Si cette Action est suivie par un appel à un service, alors elle déclenche un Effect, car certains "side-effects" vont arriver avant d'appeler le Reducer. (Voir 3.2)
Les Reducer sont des fonctions "pures" qui vont accepter 2 arguments, le State actuel et une Action. Quand une action est dispatchée, NGRX va parcourir le switch-case du reducer jusqu'à l'action correspondante.
Les Effects nous permettent de traiter ces side-effects. Ils écoutent chaque action dispatchée, et comme les reducers, vérifient si ils ont un cas pour cette action. Ensuite ils effectuent leur side-effect (généralement pour envoyer ou modifier de la donnée). Enfin, ils émettent une nouvelle action qui sera l'état final du side-effect ( Success, Error, ..), que le reducer pourra ensuite analyser. ( On revient au point 3.1)
Le State du Store a donc été renouvelé. Les Selectors vont permettre d'utiliser des parties de la donnée dont à besoin un Component.
Les Selectors permettent de récupérer et transformer la donnée avant de la renvoyer au component. Les Selectors peuvent aussi récupérer uniquement des "parts" de la donnée.
Le Store est une instance de la classe NGRX Store utilisé pour lier les Actions, Reducers et Selectors ensembles. Par exemple, quand une action est propagée, le Store va trouver et exécuter le bon Reducer.
Il détient aussi le State de l'application.
Afficher une TodoList
Cet example fait partie d'un projet de découverte de ngrx.
Nous allons reprendre les parties du fonctionnement expliquées plus haut.
Le setup
Les librairies
@ngrx/schematics : Schematics fournit des commandes Angular CLI pour générer des fichiers à la construction de fonctionnalités ngrx.
@ngrx/store : Store est un state manager RxJS pour les applications Angular inspiré par Redux.
@ngrx/effects : Effects est un modèle de side-effects RxJS pour le Store.
@ngrx/entity : Entity fournit une API pour manipuler et requêter des collections d'entités.
@ngrx/store-devtools : Store Devtools fournit des outils de développement pour le Store.
L'architecture de notre projet
app
│
└───...
│
└───reducers
│ │ index.ts`
│
│
└───todos
│ │ └───todos-list
│ │ │ todos-list.component.ts
│ │ │ todos-list.component.html
│ │ │ todos-list.component.scss
│ │ todos.service.ts
│ │ todos.model.ts
│ │ todos.actions.ts
│ │ todos.effects.ts
│ │ todos.reducer.ts
│ │ todos.selector.ts
│
│ ...
│ app.module.ts
│ app-routing.module.ts
Schematics permet, grâce à ses commandes CLI, de générer le dossier reducers et son index.ts, et de générer les fichiers actions, effects, reducer et selector.
Le Modèle
Nous devons afficher des todos. Pour ce faire, nous avons besoin de mettre à jour notre modèle de données. Un Todo possède un id, un titre et un booléen indiquant si le todo a été fait. Il à aussi une description (non obligatoire)
todos.model.ts
export interface TodosEntity {
id: string;
done: boolean;
title: string;
description?: string;
}
Les Actions
Pour afficher des Todos, nous avons besoin de 2 actions. La première pour indiquer le chargement des données, la seconde pour indiquer que les données ont bien été récupérées.
L'action LoadTodosEntitys
possède uniquement un type ('[TodosEntity] Load TodosEntitys'
);
L'action SuccessLoadTodosEntitys
possède un type ('[TodosEntity] Success Load TodosEntitys'
) et on ajoute une propriété payload
pour définir la donnée attendue.
todos.actions.ts
import { Action } from '@ngrx/store';
import { TodosEntity } from './todos.model';
export enum TodosEntityActionTypes {
LoadTodosEntitys = '[TodosEntity] Load TodosEntitys',
SuccessLoadTodosEntitys = '[TodosEntity] Success Load TodosEntitys'
}
export class LoadTodosEntitys implements Action {
readonly type = TodosEntityActionTypes.LoadTodosEntitys;
}
export class SuccessLoadTodosEntitys implements Action {
readonly type = TodosEntityActionTypes.SuccessLoadTodosEntitys;
constructor(public payload: {todos: TodosEntity[]}) { }
}
export type TodosEntityActions =
LoadTodosEntitys |
SuccessLoadTodosEntitys;
Le Reducer
Maintenant que nous avons nos Actions, nous allons pouvoir mettre à jour le Reducer.
Le TodoState étends l'EntityState qui est une interface de @ngrx/entity. L'EntityState comprend une liste d'ids et un dictionnaire d'entities ( ici TodoEntity ). Ici, le TodoState étends l'EntityState pour ajouter la propriété
selectEntities
mais ce n'est pas obligatoire.L'Adapter permet de faciliter la manipulation des TodosEntity. Il va notamment permettre de créer l'initialState de l'application et d'obtenir des Selectors (ici selectAll).
La fonction todoReducer prend en paramètre un State et une action, et va renvoyer un TodoState. Dans le cas d'une action de type
LoadTodosEntitys
, qui avait seulement un type, on va renvoyer un nouveau State. Dans le cas de l'actionSuccessLoadTodosEntitys
, qui prenait aussi en paramètre un tableau de TodoEntity, on va se servir de l'adapter pour renvoyer un State auquel on ajoute les données récupérées (payload).
todos.reducer.ts
export interface TodoState extends EntityState<TodosEntity> {
selectEntities: TodosEntity;
}
export const adapter: EntityAdapter<TodosEntity> = createEntityAdapter<TodosEntity>();
export const initialState: TodoState = adapter.getInitialState({
selectEntities: undefined
});
export const {
selectAll: selectTodosEntities
} = adapter.getSelectors();
export function todoReducer(
state = initialState,
action: TodosEntityActions
): TodoState {
switch (action.type) {
case TodosEntityActionTypes.LoadTodosEntitys: {
return {
...state
};
}
case TodosEntityActionTypes.SuccessLoadTodosEntitys: {
return {
...adapter.addAll(action.payload.todos, state)
};
}
default: {
return state;
}
}
}
L'Effect
Comme nous l'avons dit plus haut, l'Action propagée peut être suivie d'un side-effect.
Ici, un Effect est déclenché par l'action LoadTodosEntitys
, qui va ensuite appeler notre service pour récupérer les todos.
Quand les todos sont chargés, l'Effect propage une nouvelle action, la SuccessLoadTodosEntitys
( elle prenait en paramètre un
payload, nos todos récupérés).
todos.effects.ts
@Injectable()
export class TodoListEffects {
@Effect() LoadTodos$: Observable<TodosEntityActions> = this.actions$
.pipe(
ofType(TodosEntityActionTypes.LoadTodosEntitys),
switchMap(action => this.todoListService.getTodo()),
map(todos => new SuccessLoadTodosEntitys({todos}))
);
constructor(
private todoListService: TodosService,
private actions$: Actions,
) {
}
}
Le Service
todos.service.ts
export class TodosService {
constructor(private httpClient: HttpClient) { }
getTodo(): Observable<TodosEntity[]> {
return this.httpClient.get<TodosEntity[]>('/todos');
}
}
Le root Reducer
À ce stade, nous pouvons mettre à jour notre reducer/index.ts C'est le root Reducer. Il lie entre eux les différents éléments ( States, Reducer, Effects..)
Sur cette application nous avons seulement des Todos. Mais si nous avions aussi des User (par exemple), nous aurions pu ajouter un UserState, un UserReduser, un USER_TOKEN et un UserEffects.
index.ts
export interface State {
todos: TodoState;
}
export const reducers: ActionReducerMap<State> = {
todos: todoReducer
};
export function getReducers() {
return reducers;
}
export const REDUCER_TOKEN = new InjectionToken<ActionReducerMap<State>>('Registered Reducers');
export const appEffects = [TodoListEffects];
Le Selector
Nous pouvons aussi mettre à jour nos Selector.
La constante selectTodoListState$
permet de récupérer le TodoState depuis le rootReducer (index.ts)
fromTodos.selectTodosEntities
est une référence vers selectAll de l'adapter ( dans le reducer ).
La constante selectTodoListEntitiesConverted$
permet de sélectionner tous les todos grâce à l'adapter.
todos.selector.ts
export const selectTodoListState$ = (state: State) => state.todos;
export const selectTodoListEntitiesConverted$ = createSelector(
selectTodoListState$,
fromTodos.selectTodosEntities
);
Le Component
Enfin, notre component va pouvoir souscrire au store et dispatcher l'action LoadTodosEntitys
todos-list.component.ts
export class TodosListComponent implements OnInit {
public todos$: Observable<TodosEntity[]>;
constructor(private router: Router, private store: Store<State>) {
this.todos$ = store
.pipe(select(selectTodoListEntitiesConverted$));
}
ngOnInit() {
this.store.dispatch(new LoadTodosEntitys());
}
}
Conclusion
La courbe d'apprentissage de NGRX est assez raide et le flow n'est pas évident. De plus, NGRX est assez verbeux: outre les différents fichiers utilisés ( reducers, selectors, actions, effects .. ), à chaque propriété ajoutée au State, il faut mettre à jour le Store, le Reducer, le Selector, et ajouter les Action, parfois ajouter d'autres Selectors.
NGRX est très utile quand vous avez plusieurs acteurs externes qui peuvent modifier votre application, comme par exemple un dashboard de surveillance. Dans ce cas, il est difficile de gérer toutes les données poussées à l'application et la gestion d'états devient difficile. Le State étant immutable, il ne sera jamais modifié mais plutôt "écrasé" afin d'éviter les effets de bord.
Aussi, NGRX permet de réutiliser les composants en favorisant la suppression des @Input(), d'éviter les effets de bords grâce aux Effects et au principe de Single Responsability, et de rendre le débug et les tests plus simples car les fonctions sont pures et NGRX et RxJS ont de bonnes features de tests.
De plus, une fois NGRX compris, le dataflow de l'application est plus fluide. Les données sont centralisées dans le Store ce qui va aussi permettre de gagner en performance.
NGRX est donc recommandé si :
Plusieurs acteurs indépendants les uns des autres peuvent modifier la donnée.
La donnée est utilisée à plusieurs endroits de l'application et la faire transiter risque de briser le principe de Single Responsability