Les étapes pour gérer les états d’une application de manière asynchrone avec NgRx

NgRx est une librairie pour Angular qui s’inspire du modèle Redux (qui est une librairie Javascript de gestion d’états open source pour React). Cette librairie permet de gérer les états d‘une application de manière asynchrone.

NgRx repose sur 3 concepts :

  • Avoir une unique source de vérité pour toute l’application.
  • Immuable, l’état n’est pas modifié, mais remplacé. Disponible en lecture seule.
  • Elle fonctionne sous forme d’abonnement à des actions.

Pourquoi utiliser NgRx ?

De manière générale, la gestion des états d’une application se complexifie avec le temps et lorsque celle-ci commence à devenir de plus en plus riche. Chaque appel de service aura un impact sur l’état et nous pouvons appeler plusieurs fois un service particulier dans différents composants. En centralisant les informations dans un store, nous réduisons les effets de bords et la complexité de la résolution d’éventuels bugs liés aux états. Cela permet aussi de réduire l’utilisation des @Input() et @Output() qui peuvent selon la hiérarchie de nos composants devenir complexes.

Le fonctionnement de NgRx se base autour d’un store qui contient les différents états de nos modèles. L’état pourra uniquement être modifié par l’intermédiaire d’une action via un dispatch (permet de notifier l’effect de l’action que l’on souhaite appeler). L’effect capture le dispatch. Dans l’effect on appelle un service qui va nous renvoyer en réponse le futur état. Enfin, l’état se modifie finalement via le reducer (permet de remplacer l’état courant par un nouvel état).

Nous allons prendre l’exemple d’un cas avec un store qui contiendra les informations concernant une liste de livres.

  • State/état : contient l’état du modèle, qui sera modifié

Nous allons commencer par définir l’état initial de notre modèle. Cet état sera remplacé par la suite.

export const initialState: BookState = {
    books: []
}

  • Action : elle est définie par un type et le payload associé

Dans notre cas nous avons défini deux types, un pour récupérer les livres, et un pour spécifier que les livres ont bien été récupérés. En complément il faut une action pour gérer aussi le cas d’erreur.

export enum BookActionType {
	LOAD_BOOKS = '[book] load books',
	LOADED_BOOKS = '[book] loaded books',
	LOADED_ERROR_BOOKS = '[book] loaded books error'
}
export const loadBooks = createAction(BookActionType.LOAD_BOOKS)
export const loadedBooks = createAction(BookActionType.LOADED_BOOKS, props<{ books: Books}>())}
export const errorBooks = createAction(BookActionType.LOADED_ERROR_BOOKS)

  • Effect : intercepte le dispatch en fonction du type de l’action émise. Émet une nouvelle action, dans le cas de succès et d'erreur

Lorsque l’effect reçoit une action avec le type « [book] load books », cela déclenche un appel au service pour le chargement des livres. Dans tous les cas une action sera retournée.

loadBookEffect$ = createEffect(() => this.action$
    .pipe(ofType(booksActions.loadBooks),
        mergeMap(() => this.booksService.loadBooks()
            .pipe(
                map((books: Books) => {
                    return booksActions.loadedBooks({books: books})
                }),
                catchError(() => {
                    return of(booksActions.erroBooks())
                })
            )
        )
    )
)

  • Reducer : modifie l’état final, prend en paramètre le type de l’action, ainsi que le nouvel état à mettre dans le store et retournera au passage le nouvel état du store.

Lorsque le reducer recevra dans ce cas une action de type loadedBooks, alors l’état sera remplacé.

on(loadedBooks, (state, {books}) => ({
    ...state,
    books: books
}))

  • Selector : permet de sélectionner l’état dans son intégralité ou alors une partie de l’état liée à un modèle à récupérer
export const selectBooks = (state: BookState) => state.books;

export const selectBooks = createSelector(
    selectBooks,
    (state: BookState) => state.books
);

Pour accéder au store, il suffira de faire un appel au select (selectBooks), qui va récupérer l’état de notre modèle.

Pour modifier le store il faudra passer par un dispatch, avec l’action que nous souhaitons.

this.store.dispatch(bookAction.loadBooks());
this.store.select(bookSelector.selectBooks).
    subscribe(books => {
        this.books = books
    });

Tips & Tricks

  • Outils de debug

Corriger une erreur qui proviendrait du store peut être fastidieux. Aujourd'hui, il existe Redux DevTools qui peut être utilisé aussi pour NgRx. Cela va vous permettre de faire du debug tout en ayant une vue sur l’état du store.

  • Bien décrire ses actions
export enum BookActionType {
    LOAD_BOOKS = '[book] load books',
    LOADED_BOOKS = '[book] loaded books',
    LOADED_ERROR_BOOKS = '[book] loaded books error'
}


Dans l’exemple donné plus haut pour mettre en place les différentes actions, il est fortement recommandé de décrire ses actions correctement. En effet, il n’est pas forcément simple de débugger les parties qui concernent le store de l’application. En précisant le nom de l’entité qui sera impactée par le changement d’état, on peut déjà gagner du temps.

  • Attention au switchmap de RxJS
loadBookEffect$ = createEffect(() => this.action$
    .pipe(ofType(booksActions.loadBooks),
        mergeMap(() => this.booksService.loadBooks()
            .pipe(
                map((books: Books) => {
                    return booksActions.loadedBooks({books: books})
                }),
                catchError(() => {
                    return of(booksActions.erroBooks())
                })
            )
        )
    )
)


Dans l’exemple donné plus haut, nous avons délibérément utilisé un mergemap car la situation le permettait. Dans certains cas vous aurez l’envie d’utiliser un switchmap. Si l’on prend l’exemple de la suppression d’un livre de la liste, que dans le template vous ayez un bouton de suppression pour chaque élément de la liste.

Imaginons que malheureusement, vous tombiez sur un utilisateur impatient et que ce dernier se mette à cliquer (avec acharnement) sur les différents boutons. Nous passerons dans le switchmap, la dernière requête sera annulée et on relancera une requête pour la suppression d’un livre (ce serait le comportement idéal). Malheureusement, il se peut qu’un livre d’une des dernières requêtes soit supprimé. Cet opérateur est donc à utiliser avec précaution dans l’effect.

Conclusion

Le store n’est pas forcément adapté à n’importe quel projet. Avant de s’orienter vers cette solution, il est préférable de réfléchir au préalable aux raisons de son utilité, en fonction de la taille de l’application, de la quantité de données à gérer et de la manière dont l’application doit se comporter en fonction de ces données. Car la mise en place d’un store dans un projet angular n’est pas forcément très simple quand on découvre ce concept et cela reste une étape supplémentaire à prendre en compte lors du développement d’une nouvelle feature (il y aura au minimum 3 fichiers à toucher pour gérer un état).

Si le volume de données est important et s’il y a un nombre conséquent de composants qui vont exploiter ces données, on peut opter pour l’utilisation d’un store.

En revanche, si l'application est plus légère et simple, il est préférable de ne pas passer par le store et d’échanger les données entre composants père/fils avec les notions d’entrées et sorties, ou via des appels de services.