La gestion d’état réactive sur Angular avec NgRx

Introduction

Étant un habitué de l'environnement React / Redux, j’ai souhaité explorer l’univers de la programmation réactive et de gestion d’état du côté d’Angular et m’intéresser aux outils de cet écosystème. J’ai ainsi découvert NgRx, ses principes et son fonctionnement.
Après une présentation sur les concepts d’architecture, j’illustrerai ces propos à travers une application CRUD assez simple, une todo list sur le thème des films.

Pour cet article je m’appuierai uniquement sur le module principal @ngrx/store.


Concepts d’architecture

La programmation réactive est un paradigme de programmation permettant de garantir une cohérence d’informations au sein d’un même système. S’appuyant sur le pattern Observer, elle met en place un flux de données par lequel transitent des modifications issues d’une source, qui sont propagées aux éléments dépendants de cette source.

Comme Redux, NgRx utilise ce paradigme pour proposer une gestion d’état centralisée en s’appuyant sur des notions d’architecture et de pattern similaires.

Actions

Les actions sont les objets au cœur de la logique de gestion d'états dans une application réactive. Dans NgRx, ils s’appuient sur l’interface Action :

interface Action { type: String }

C’est un objet composé au minimum d’un champ type ainsi que de valeurs si besoin. Le champ type permet de donner des informations sur le rôle et la fonctionnalité associée à cette action.

En pratique, NgRx propose une méthode createAction() permettant de créer un objet action. Une fois créé, notre objet est propagé sous forme d‘événement, afin d’être transmis à l’ensemble de l’application et intercepté par un reducer.

Reducers

Les reducers sont des fonctions pures qui ont pour rôle d’appliquer une transformation de l’état selon une action émise. Leurs applications ne provoquent pas d’effet de bord : pour un ensemble de paramètres donnés, le résultat sera toujours le même.
Ils prennent en paramètre la dernière action émise et l’état actuel de l’application. Selon l’action interceptée, ces fonctions renvoient un nouvel objet représentant l’état modifié ou l’état actuel.

Encore une fois, NgRx fournit une méthode createReducer() pour mettre en place ce mécanisme. L’objet est ensuite transmis au store.

Store

L’expression Single source of truth (source unique de vérité) est ce qui caractérise la notion de store. Il s’agit d’un objet, comportant différents champs, qui concentre l’ensemble des données d’une application : on parle de l’état d’une application. Les différents composants, services et routes vont de fait s’appuyer sur cet objet pour afficher leurs interfaces, appliquer leurs méthodes et gérer la navigation.
Dans le paradigme Observer évoqué précédemment, le store est donc à la fois observateur et observable. D’une part, grâce à des actions, les reducers interprètent le nouvel état de l'application, qui sera appliqué dans le store. D’autre part, les différents composants dépendant du store se mettent à jour en fonction de ses nouvelles valeurs.

On peut résumer ces concepts à l’aide du schéma suivant :

Schéma du cycle de vie de la gestion d’état

Correspondance des étapes :
  1. Un composant propage une action à la suite d’une interaction.
  2. L’action est interceptée par un reducer.
  3. En fonction du type de l’action, un nouvel objet est renvoyé au store pour que celui-ci se mette à jour avec cet objet.
  4. Les composants dépendants du store se mettent à jour en fonction des nouvelles valeurs.

D’autres principes peuvent être mis en œuvre, comme les sélecteurs, qui permettent dans le cas d’un store plus complexe de faciliter la sélection des valeurs de l’état à utiliser, ou encore les méta-reducers qui agissent sur le même fonctionnement des middleware de Redux. Pour l’exemple qui suit, nous n'aurons pas besoin de ces outils.


Application des concepts

Afin d’illustrer les différents concepts évoqués, j’ai mis en place une application CRUD de liste de films à voir.

Contexte

Le projet se compose d’un champ de saisie permettant d’ajouter un film par son titre, et d’une liste des différents films saisis, organisée selon le statut de visionnage. Chaque film peut être supprimé de la liste et marqué comme vu (ou non vu).

Interface de l'application

Pour représenter chaque élément, j’ai défini une interface de type Film, composée d’un id, d’un titre et d’un champ booléen “seen” indiquant si oui ou non le film a été vu.

interface Film { id: number, title: string, seen: boolean }

De plus, un composant Films contient l’ensemble de la logique CRUD et constitue le point d’entrée pour lier le template d’affichage et le store.

films.component.ts-3

L’attribut films$ qui contient l'ensemble des films est déclaré comme un Observable de tableau de Film, car dépendant des valeurs du store. Celui-ci est injecté dans le constructeur et la méthode select() permet d’en récupérer les valeurs. Le tableau final est utilisé pour afficher les différentes lignes de nos listes. J’y ai appliqué une méthode sort() pour afficher les films non vus en premier.

Enfin, l’état de base est enrichi de 3 exemples initiaux pour illustrer les possibilités.

Déroulement : ajout d’un film

Observons maintenant le fonctionnement de l’application lors de l’ajout d’un film à notre bibliothèque, par exemple Titanic.

Ajout du film Titanic

Comme décrit précédemment, chaque opération démarre par la création d’une action.

Nous utilisons donc la méthode createAction() de NgRx avec les paramètres suivants : le nom de l’action (AddFilm) et son origine (le composant Film), et la méthode props(), issue aussi de NgRx, qui permet d’inclure si besoin des informations supplémentaires. On va donc pouvoir passer en paramètre un film de type de notre interface Film.
Une fois créée, notre action doit être propagée par la méthode dispatch() du store.

Comme un événement, cette action est interceptée, par l’intermédiaire d’un reducer.

On utilise la méthode createReducer(), qui prend en paramètre un état initial et autant de listeners d’actions nécessaires. Dans notre exemple, on va se concentrer sur le listener de l’action AddFilm.

on(addFilm, (state, {film}) => ([...state, film]))

Cette ligne permet de définir les instructions à effectuer lorsque cette action est propagée : on récupère en paramètre l’état actuel (state) et notre valeur (film) et on retourne un nouvel objet qui sera notre nouvel état. Grâce à la syntaxe de décomposition, je retourne un nouveau tableau auquel j'ajoute mon nouveau film à la fin.
Une fois cette étape passée, notre film est bien ajouté en mémoire de notre état et notre interface se met à jour.

Film Titanic ajouté

Conclusion

Sans surprise, on retrouve une manière de penser similaire aux outils de gestion d’état réactive, avec des terminologies et des principes familiers. Pour les habitués de Redux, la prise en main est donc plutôt facile. Il est bien entendu nécessaire de connaître un minimum Angular pour son implémentation.
Sans négliger les réflexions initiales de l’intérêt d’utiliser un store, NgRx est donc une alternative complète à Redux qui pourra trouver sa place dans vos prochaines applications Angular.

Aller plus loin

NgRx propose d’autres modules qui permettent de faciliter son intégration :

@ngrx/schematics : outil intégré à Angular CLI permettant de générer les fichiers utiles à NgRx (reducers, actions, etc.).
@ngrx/router-router : binder le router de l’application au store.
@ngrx/entity : gestion de collection d'entité avec des opérations CRUD et reducers générés
@ngrx/effects : support de l’utilisation d’actions issues de ressources externes comme un serveur de données.

Une bonne connaissance des principes de base énoncés dans l’article permet de prendre en main plus efficacement ces modules.

Références

NgRx - Documentation officielle https://ngrx.io/