NGRX Introduction

Cet article vous permettra de vous faire une meilleure idée de l'implémentation de redux dans Angular via NGRX.

Malorie Berthoin

Développeur

Malorie Berthoin
NGRX Introduction

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:

  1. Single source of truth : Le State est stocké dans un Store unique.

  2. State readonly : Immutabilité du State. Une fois créé, la seule façon de le modifier est de le recréer.

  3. 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

  1. Le Component qui à besoin d'afficher, modifier ou supprimer une donnée du Store va propager (dispatcher) une Action.

  2. 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'action
    • payload: le type de cette propriété dépend du type de données que l'action a besoin d'envoyer au reducer.
  3. 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)

    1. 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.

    2. 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)

  4. 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.

image

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

  1. 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.

  2. 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.

  1. 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.

  2. 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).

  3. 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'action SuccessLoadTodosEntitys, 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