Tutoriel pour apprendre à implémenter l'architecture flux avec React

Nordwin HOFF

Lorsque je développe une application, j'essaie toujours de me poser l'éternelle question : « Où est-ce que je mets ce code ? ». Côté back et notamment en Java (mais pas que), cela fait au moins une dizaine d'années que l'on a des éléments de réponse. M'étant plongé dans le monde fantastique du développement Front depuis l'émergence d'AngularJS, la question résonne d'autant plus que la structure du code a évolué depuis quelques années. On connaît évidemment l'incontournable MVC, et peut-être un peu moins ses différentes variantes. AngularJS propose aujourd'hui un pattern plus proche du MVVM.
Aujourd'hui, je vais vous présenter le pattern proposé par Facebook, qui offre une alternative au MVC et ses dérivés : « Flux ».

Cet article a pour but de décrire le pattern Flux. En revanche, étant fortement couplé à React et mis en œuvre de pair avec cette bibliothèque, on confondra ici Vue et composant React.

Commentez

- Titre : Tutoriel pour apprendre à implémenter l'architecture flux avec React

- Auteur : Nordwin HOFF

- Parution : 18 janvier 2017

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2016 Soat. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Retour sur le MVC

- I-A. Le MVC avec Spring

- I-B. AngularJS et son MVW

- II. Flux

- II-A. Pourquoi Flux ?

- II-B. Le pattern

- II-B-1. Les Actions

- II-B-2. Le dispatcher

- II-B-3. Les stores

- II-B-4. Les vues

- II-B-5. Tous ensemble

- II-C. Le code

- II-C-1. Les Actions

- II-C-2. Le dispatcher

- II-C-3. Les stores

- II-C-4. Les vues

- II-C-5. Tous ensemble

- III. Aller plus loin avec Redux

- IV. Remerciements

I - Retour sur le MVC

Flux ayant émergé suite à des soucis d'utilisation du pattern MVC chez Facebook, je vais revenir dessus en tant que base pour poser la problématique.

I-A - Le MVC avec Spring

Regardons une des façons de faire du MVC côté Java. Un schéma étant plus parlant que de grands discours, voici le fonctionnement de Spring MVC :

L'important ici n'est pas de se focaliser sur les aspects du traitement des requêtes HTTP (servlet, etc.), mais plutôt de regarder la communication entre les différentes briques.

On voit que, dans ce cas-ci, le Controller principal transmet la requête au Controller concerné. Celui-ci va renvoyer un modèle contenant toutes les informations voulues. Ce modèle va être transmis à la vue correspondante par le Controller principal, et celle-ci va extraire les informations du modèle pour les afficher. Le résultat final est la réponse HTTP envoyée par le Controller principal. On a donc des échanges bidirectionnels entre chaque brique du MVC, orchestrés par un Controller principal.

I-B - AngularJS et son MVW

Maintenant, un exemple côté JavaScript. Encore une fois, commençons par quelques illustrations. Dans le cas d'AngularJS, le pattern mis en œuvre est un peu différent du MVC classique, puisqu'il n'y a pas de controller qui s'occupe d'orchestrer les échanges entre les éléments du MVC. On a plutôt un système géré par les événements. On parle très souvent de MVVM pour Model, View, ViewModel. Ce pattern est orienté événements et l'idée générale est la suivante :

En fait, ici on ne voit pas le ViewModel. C'est le composant qui permet la boucle de mise à jour de la vue en fonction du modèle. C'est pour cela que le nom officiel est MVW, pour Whatever, parce qu'il n'est finalement pas important de se concentrer sur le composant qui fait le lien. Ce qui est important, c'est le fameux two-way databinding basé sur les événements. Le détail du fonctionnement de la boucle d'événement est disponible ici.

II - Flux

II-A - Pourquoi Flux ?

Maintenant que le MVC est frais dans notre tête, on peut se demander pourquoi utiliser un autre pattern. Après tout, si le MVC a du succès, c'est qu'il répond à un grand nombre de problématiques. C'est assez vrai en général, mais on rencontre plus souvent qu'on ne croit des cas où le MVC peut être source de problèmes. Et c'était le cas chez Facebook.

Flux a été présenté lors de la conférence F8 de 2014.
En résumé, Facebook a rencontré un bug au niveau des notifications du chat. Le compteur de notification affichait une valeur 1 alors qu'il n'y avait pas de notification dans la liste. Le bug a été identifié puis corrigé, mais il est réapparu par la suite. La source de ce cercle vicieux venait de l'implémentation du MVC.

Il faut imaginer que, dans une application à taille réelle - j'entends par là une centaine de composants au moins avec chacun sa logique métier et sa vue -, on peut vite arriver à avoir beaucoup de modèles qui sont référencés dans beaucoup de vues, des données calculées les unes à partir des autres, et du code appelé à plusieurs endroits, ce qui nous amène rapidement à du code spaghetti. Et avec le MVC, les données transitent à double sens entre chaque composant. Ajoutez à ça les contraintes opérationnelles qui peuvent pousser à faire l'impasse sur un code de qualité, à tort ou à raison, et on comprend mieux pourquoi ça peut devenir compliqué !

Prenons une application AngularJS qui partage un objet à travers un service, lui-même injecté dans différents contrôleurs pour rendre accessible l'objet en question dans les vues associées. La référence de l'objet se retrouve à peu près partout, ses attributs étant modifiables depuis chaque vue. Il devient très difficile, à la simple lecture du code, de savoir à quel moment l'objet est modifié.

II-B - Le pattern

Pour simplifier les problèmes éventuels décrits ci-dessus, Flux propose une transmission de données unidirectionnelle et des composants qui ont chacun une responsabilité clairement identifiée :

II-B-1 - Les Actions

En rouge sur le schéma.
Dans une architecture Flux, tout passe par les actions. On ne peut pas modifier l'affichage d'un composant ou déclencher un comportement sans action. C'est à partir d'une action qu'on pourra modifier le state d'un composant React par exemple. Si le code de l'action est complexe, on peut séparer sa création et l'instance elle-même (ce qui explique les deux briques du schéma). C'est dans ce composant qu'on s'interfacera avec des API externes à l'architecture Flux. Typiquement, les actions sont déclenchées par un clic dans une GUI, ou bien peuvent provenir du serveur via une websocket, par exemple.

II-B-2 - Le dispatcher

En noir sur le schéma.
Le dispatcher est le composant unique qui reçoit toutes les actions de l'application. Son rôle est de notifier tous les stores via des callbacks que l'action a eu lieu.

II-B-3 - Les stores

En marron sur le schéma.
Les stores sont les composants de Flux qui vont contenir et gérer les states de l'application. Ils fournissent au dispatcher les callbaks exécutés lors de la notification d'une action. Il va donc contenir l'implémentation de toutes les règles de gestions du domaine qu'il couvre. Il va également gérer les actions qu'il veut traiter, car comme expliqué ci-dessus, le dispatcher notifie les stores de toutes les actions de l'application. Chaque store s'occupera d'une partie du fonctionnel de l'application et ne voudra donc pas traiter toutes les actions. Pour finir, les stores vont notifier par événement les changements d'état aux vues leur correspondant.

II-B-4 - Les vues

En bleu sur le schéma.
Les vues sont chargées d'afficher le state contenu dans le store associé. Elles s'enregistrent auprès du store pour être notifiées des changements de state, pour se mettre à jour en conséquence.

II-B-5 - Tous ensemble

Finalement, si on reformule l'enchaînement de tout ça, il y a deux phases :

- à l'initialisation : le dispatcher est instancié de manière unique. Chaque store s'enregistre auprès de lui via un callback et choisit de traiter une liste d'actions prédéfinies. Chaque vue s'abonne à un événement dans le but de se mettre à jour pour refléter le changement de state du store ;

- suite à une action : une action est déclenchée et est envoyée au dispatcher, qui va exécuter tous les callbacks de tous les stores pour propager l'action en cours. Chaque store qui l'a prévu va exécuter les règles associées à cette action et mettre son state à jour en fonction. Si le state est mis à jour, un événement est déclenché pour dire aux vues associées au store de se rafraîchir pour traduire le changement de state du store.

II-C - Le code

Le paragraphe précédent est très théorique. Je propose donc un exemple de code succinct pour illustrer le propos. On ne présentera pas ici les nombreuses implémentations disponibles de Flux. D'ailleurs, Facebook a publié, sur github, un ensemble utilitaire pour modéliser simplement le pattern. Au vu de la simplicité du pattern, l'exemple ci-après va exploiter ces flux-utils. Quitte à utiliser un framework pour mettre en place un pattern de type flux, je recommanderais plutôt la mise en place de redux. Nous y reviendrons dans le paragraphe suivant.

Regardons donc Flux au travers d'un exemple.
Nous allons prendre le cas d'un stock de produits que l'on peut ajouter à un panier. Si on ajoute un produit au panier, cela diminue le stock dans le rayon et l'augmente dans le panier (logique) et inversement.
Le tout est packagé par Webpack.

II-C-1 - Les Actions

Pour commencer, faisons l'inventaire des actions. On peut ajouter ou supprimer un article du panier, ce qui va entraîner une diminution ou une augmentation du stock. Donc nous aurons ADD_TO_BASKET, REMOVE_FROM_BASKET, FILL_STOCK et EMPTY_STOCK.

basketAction.js

1. import FluxAppDispatcher from '../dispatcher/fluxAppDispatcher';

2. import Constants from '../constants';

4. export default class BasketAction {

5. static addProduct(product) {

6. const addBasketActionObject = {

7. type: Constants.ADD_TO_BASKET,

8. payload: product,

9. };

10. FluxAppDispatcher.dispatch(addBasketActionObject);

11. }

13. static removeProduct(product) {

14. const removeBasketActionObject = {

15. type: Constants.REMOVE_FROM_BASKET,

16. payload: product,

17. };

18. FluxAppDispatcher.dispatch(removeBasketActionObject);

19. }

20. }

stockAction.js

1. import FluxAppDispatcher from '../dispatcher/fluxAppDispatcher';

2. import Constants from '../constants';

4. export default class StockAction {

5. static increaseStock(stock) {

6. const increaseStockActionObject = {

7. type: Constants.FILL_STOCK,

8. payload: stock,

9. };

10. FluxAppDispatcher.dispatch(increaseStockActionObject);

11. }

13. static decreaseStock(stock) {

14. const decreaseStockActionObject = {

15. type: Constants.EMPTY_STOCK,

16. payload: stock,

17. };

18. FluxAppDispatcher.dispatch(decreaseStockActionObject);

19. }

20. }

Dans le code, on voit la différence entre les ActionCreator (ici les classes elles-mêmes) et les actions à proprement parler. Les variables appelées actionPayload sont les objets modélisant les actions. Ici, ce sont de simples conteneurs.

II-C-2 - Le dispatcher

Le dispatcher est le code le plus simple, puisqu'on utilise directement l'objet fourni par la dépendance « flux » de Facebook :

fluxAppDispatcher.js

import Flux from 'flux';
export default new Flux.Dispatcher();

Le dispatcher devant être unique, on exporte une instance du Dispatcher et non le prototype seul. C'est cette instance qui sera utilisée tout au long de la durée de vie de l'application.

II-C-3 - Les stores

Nous allons construire deux stores : un pour gérer l'état du panier, l'autre pour gérer l'état du rayon. Regardons deux implémentations différentes. La première reposera sur l'EventEmmitter de NodeJS pour gérer les événements envoyés aux vues. La seconde exploitera directement la classe FluxStore exposée en tant que Store par le module flux/utils.

shelfStore.js

1. import Constants from '../constants';

2. import Dispatcher from '../dispatcher/fluxAppDispatcher';

3. import EventEmmiter from 'events';

4. import initProduct from '../data.json';

5. const products = initProduct;

7. class ShelfStore extends EventEmmiter {

9. emitChange() {

10. this.emit(Constants.CHANGE_EVENT);

11. }

13. addChangeListener(callback) {

14. this.on(Constants.CHANGE_EVENT, callback);

15. }

17. removeChangeListener(callback) {

18. this.removeListener(Constants.CHANGE_EVENT, callback);

19. }

21. getState() {

22. return products;

23. }

24. }

26. const shelfStore = new ShelfStore();

28. shelfStore.token = Dispatcher.register((actionPayload) => {

29. console.log('ShelfStore', actionPayload);

30. const product = products.find(item => item.id === actionPayload.payload.id);

31. if (actionPayload.type === Constants.FILL_STOCK) {

32. product.quantity += 1;

33. shelfStore.emitChange();

34. } else if (actionPayload.type === Constants.EMPTY_STOCK) {

35. product.quantity -= 1;

36. shelfStore.emitChange();

37. }

38. });

40. export default shelfStore;

On voit ici les différents mécanismes de gestion des événements implémentés à la main. ShelfStore hérite de EventEmmiter. La méthode d'émission de l'événement, l'ajout et la suppression des listeners du store permettent d'encapsuler les appels aux méthodes de la classe EventEmmiter.

Le callback enregistré auprès du dispatcher est ajouté sur l'instance du store créée directement avec la définition de la classe. A priori, on n'a pas besoin de plus d'une instance de ce store. C'est dans ce callback qu'on définit les actions qui seront traitées et le code associé. Ici, on traitera les deux actions permettant de gérer le stock de produits dans le rayon FILL_STOCK et EMPTY_STOCK. L'instruction console.log est ajoutée à des fins pédagogiques, pour illustrer le fonctionnement global du flux de données.

basketStore.js

1. import Constants from '../constants';

2. import { Store } from 'flux/utils';

3. import { remove } from 'lodash';

4. const products = [];

6. export default class BasketStore extends Store {

7. getState() {

8. return products;

9. }

11. __onDispatch(actionPayload) {

12. console.log('BasketStore', actionPayload);

13. if (actionPayload.type === Constants.ADD_TO_BASKET) {

14. const product = products.find(item => item.id === actionPayload.payload.id);

15. if (product) {

16. // already in basket

17. product.quantity += 1;

18. } else {

19. // new in basket

20. const newProduct = actionPayload.payload;

21. newProduct.quantity = 1;

22. products.push(newProduct);

23. }

24. this.__emitChange();

25. } else if (actionPayload.type === Constants.REMOVE_FROM_BASKET) {

26. const product = products.find(item => item.id === actionPayload.payload.id);

27. if (product.quantity > 1) {

28. product.quantity -= 1;

29. } else {

30. // delete from basket

31. remove(products, item => item.id === product.id);

32. }

33. this.__emitChange();

34. }

35. }

36. }

BasketStore, en revanche, est construit en tirant parti des flux utils et hérite de la classe Store. Comme décrit dans la documentation de flux utils, on passe en paramètre l'instance du dispatcher en paramètre du constructeur, ce qui permet de déléguer la partie technique à la classe Store. Il nous suffit simplement de fournir l'implémentation du callback enregistré auprès du Dispatcher, ici en surchargeant la méthode onDispatch et en appelant emitChange pour déclencher l'événement.

Ce store gère les actions liées au panier. On a donc le code qui traite ADD_TO_BASKET et REMOVE_FROM_BASKET.
Si besoin, les flux utils proposent d'autres implémentations de stores en fonction du besoin au travers des classes ReduceStore et MapStore.

II-C-4 - Les vues

Je décrirai uniquement les vues en interaction directe avec les autres composants de l'architecture flux.
Commençons par les composants React qui déclenchent les actions :

shelf.jsx

1. import React from 'react';

2. import Product from './product';

3. import ItemProduct from './itemProduct';

4. import BasketAction from '../actions/basketAction';

5. import StockAction from '../actions/stockAction';

6. import { cloneDeep } from 'lodash';

8. export default class Shelf extends React.Component {

10. triggerActions(product) {

11. StockAction.decreaseStock(product);

12. BasketAction.addProduct(product);

13. }

15. render() {

16. const products = this.props.products.map(

17. (product, index) =>

18. <ItemProduct key={index} onAdd={this.triggerActions.bind(this, cloneDeep(product))}>

19. <Product product={product} withStock={true} />

20. </ItemProduct>

21. );

22. if (!products.length) {

23. return null;

24. }

25. return (

26. <div className="shelf col-lg-6">

27. <h2>Product list</h2>

28. {products}

29. </div>

30. );

31. }

32. }

34. Shelf.propTypes = {

35. products: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,

36. };

La fonction cloneDeep est utilisée pour ne pas simplement ajouter les références des produits dans le panier, mais bien des objets produits en entier. Si on ne fait pas le cloneDeep, toute opération effectuée sur le produit de l'action s'applique partout où il est référencé. Dans notre cas, cela joue sur les quantités et le stock.
Si ce n'est pas très clair, le mieux est de retirer l'appel à la fonction et de tester.

basket.jsx

1. import React from 'react';

2. import Product from './product';

3. import BasketProduct from './basketProduct';

4. import BasketAction from '../actions/basketAction';

5. import StockAction from '../actions/stockAction';

6. import { cloneDeep } from 'lodash';

8. export default class Basket extends React.Component {

10. triggerActions(product) {

11. BasketAction.removeProduct(product);

12. StockAction.increaseStock(product);

13. }

15. render() {

16. const products = this.props.products.map(

17. (product, index) =>

18. // cloneDeep to have a separate instance of product in emitted action

19. <BasketProduct key={index} onDelete={this.triggerActions.bind(this, cloneDeep(product))}>

20. <Product product={product} />

21. </BasketProduct>

22. );

23. if (!products.length) {

24. return null;

25. }

26. return (

27. <div className="basket col-lg-6">

28. <h2>My basket</h2>

29. {products}

30. </div>

31. );

32. }

33. }

35. Basket.propTypes = {

36. products: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,

37. };

La gestion des actions est très simple : on ajoute l'import et on déclenche l'action sur les événements natifs du DOM (onChange, onClick, etc.). Ici, lorsqu'on cliquera sur le bouton « add », on déclenchera les actions ADD_TO_BASKET pour ajouter le produit au panier et EMPTY_STOCK pour enlever un item du stock de produits via les méthodes addProduct et addProduct. On procède exactement de la même manière dans le fichier basket.js pour les actions REMOVE_FROM_BASKET et FILL_STOCK.

Passons aux vues qui sont en interaction avec les stores. En fait, les stores gérant les states au sens flux, il est logique que les interactions se passent avec les vues possédant le state au sens React :

shelfContainer.js

1. import React from 'react';

2. import ShelfStore from '../stores/ShelfStore';

3. import Shelf from './Shelf';

5. export default class ShelfContainer extends React.Component {

6. constructor(props) {

7. super(props);

8. this.state = {

9. products: ShelfStore.getState(),

10. };

11. }

13. componentDidMount() {

14. ShelfStore.addChangeListener(this.onChange.bind(this));

15. }

17. componentWillUnmount() {

18. ShelfStore.removeChangeListener(this.onChange.bind(this));

19. }

21. onChange() {

22. this.setState({

23. products: ShelfStore.getState(),

24. });

25. }

27. render() {

28. return <Shelf products={this.state.products} />;

29. }

30. }

basketContainer.js

1. import React from 'react';

2. import Dispatcher from '../dispatcher/fluxAppDispatcher';

3. import BasketStore from '../stores/basketStore';

4. import Basket from './basket';

6. export default class BasketContainer extends React.Component {

7. constructor(props) {

8. super(props);

9. this.store = new BasketStore(Dispatcher);

10. this.state = {

11. products: this.store.getState(),

12. };

13. }

15. componentDidMount() {

16. this.listener = this.store.addListener(this.onChange.bind(this));

17. }

19. componentWillUnmount() {

20. this.listener.remove();

21. }

23. onChange() {

24. this.setState({

25. products: this.store.getState(),

26. });

27. }

29. render() {

30. return <Basket products={this.state.products} />;

31. }

32. }

On retrouve, dans ces deux composants racines, la différence d'implémentation au niveau des stores, notamment au niveau des méthodes d'ajouts et suppressions en tant que listener.

On propose également ici d'avoir deux modèles : on importe l'instance du store (ShelfStore) ou bien la classe que l'on instancie dans le constructeur de la vue (BasketStore).
Ces composants React sont en fait les fameux controller-views évoqués sur le schéma du paragraphe suivant.

II-C-5 - Tous ensemble

Voici un autre angle moins détaillé que le premier schéma pour appréhender flux :

Je ne paraphraserai pas la description faite précédemment. J'insisterai juste sur le fait qu'il n'y a qu'une seule instance du dispatcher dans cette architecture et que les stores sont notifiés de toutes les actions. Le code de cet article inclut des logs pour mettre en exergue ce point. Par ailleurs, j'invite le lecteur à manipuler un PoC disponible sur le github prévu à cet effet.

III - Aller plus loin avec Redux

Maintenant que nous avons vu un exemple de mise en œuvre de flux, on peut dire que cela peut effectivement faciliter le contrôle du flux de données d'une application complexe. En revanche, on peut voir quelques inconvénients.

Tout d'abord, il n'y a qu'un seul dispatcher dans toute l'application, ce qui peut être problématique si jamais l'application devait gérer des centaines ou des milliers de stores et d'actions. Cela serait surtout pour des problématiques de performance plus que de code. On a vu qu'ici il tient en deux lignes.

On peut voir aussi que les callbacks fournis par les stores doivent traiter une liste d'actions au sein d'une seule fonction. Attention à ne pas tomber dans le travers d'écrire des fonctions de plusieurs centaines de lignes.
Si les actions sont interdépendantes les unes des autres (ce qui est prévu dans l'implémentation du dispatcher), on peut vite avoir du mal à s'y retrouver si elles sont nombreuses.

D'une manière générale, j'attire l'attention du lecteur sur la mise en œuvre de Flux pour des applications qui possèdent de très nombreux composants. Cela peut très bien fonctionner (la preuve, Facebook l'utilise), mais une grande rigueur s'impose.

Suite à la parution de Flux, un autre pattern dérivé a fait son apparition et semble remporter tous les suffrages en termes de popularité : Redux.

Nous n'allons pas le présenter en détail ici, mais étant le résultat d'une réflexion à partir de différentes solutions, dont Flux, il me semble intéressant de l'évoquer.
Globalement, l'idée est de fiabiliser le contrôle de l'évolution des states d'une application front, notamment les single page applications. Redux repose sur trois principes :

1. Single source of truth : le state est représenté sous forme de grappes d'objets ;

2. On ne modifie JAMAIS un state : on créera toujours une copie de l'objet à modifier ;

3. Les données sont traitées à l'aide de fonctions pures.

Par rapport à flux, le dispatcher disparaît et il n'y a plus qu'un seul store en interaction directe avec les reducers. Ce sont eux (les reducers) les fonctions pures du 3e principe. Ces principes sont décrits dans le détail dans la documentation officielle. Il y a également un grand nombre de ressources pour appréhender Redux.

Pour finir, et surtout si tout ça reste encore flou pour vous, je propose d'aller jeter un œil à ce site Internet qui propose une explication de Flux, Redux et autres principes du même écosystème : Code cartoon a été présenté lors de la React.js Conf de ce début d'année. Il propose une explication imagée à base de cartoon pour appréhender les grands principes de ces outils.

IV - Remerciements

Ce tutoriel a été publié avec l'aimable autorisation de la société Soat.

Nous remercions Winjerome pour la gabarisation et f-leb pour la relecture orthographique.