Les Stores Pas à Pas

Les Stores Pas à Pas

Les Stores sont devenus un incontournable des architectures frontend que ce soit en React, ou en Angular. Ils apportent de nombreux avantages et permettent d’obtenir une application structurée, performante et maintenable. Malgré cela, cet outil reste extrêmement mal compris par les développeurs.

L’objectif de cet article est d’expliquer, pas à pas, les différents concepts théoriques qui sont au cœur des Stores et des frameworks comme Redux. Nous allons pour cela implémenter un Store en se basant uniquement sur les outils mis à disposition par Angular, notamment RXJS. Les concepts que nous allons voir sont complètement transposables à l’écosystème React ou Vue.

Pour cet exercice, nous allons nous baser sur une application de Todo List en guise d’exemple. Le code donné en exemple s'appuiera sur la librairie RXJS. Comprendre les principes de bases de cette librairies est probablement nécessaire pour la bonne compréhension des exemples.

Les données de votre application - State

Au cœur des Stores, il y a une idée principale : les données de l’application sont suffisamment importantes pour avoir leur propre système de traitement. Cette architecture défend donc le fait que la gestion des données, de leur stockage, et de leurs mises à jour ne devraient pas faire partie des rôles du composant.

Nous allons donc commencer par extraire les données des composants avec comme critères :

  • Que les données soient facilement accessibles

  • Que les composants puissent facilement être notifiés des changements de données

La solution dans l’éco-système Angular est assez simple :

  • Placer les données dans un service.

  • Permettre aux composants de récupérer les données via une “Observable”.

  • Initialiser les données par défaut.

Nous pouvons donc répondre à nos besoins avec le code suivant :

@Injectable({
  providedIn: 'root',
})
export class Store {
  private state$ = new BehaviorSubject<any>({});
}

BehaviorSubject est un objet fourni par RXJS. Il permet :

  • De suivre l’évolution des données via une observable.

  • D’avoir une valeur par défaut, que l’on ait des souscriptions ou non.

Pour notre exemple, nous allons introduire deux données dans notre application :

  • La liste des tâches

  • L’utilisateur courant

Le format de notre état va être décrit via une interface ApplicationState

export interface ApplicationState {
  todos: Todo[];
  user?: User;
}

@Injectable({
  providedIn: 'root',
})
export class Store {
  private state$ = new BehaviorSubject<ApplicationState>({
    todos: [],
  });
}

Notre état est maintenant disponible, il ne reste plus qu’à l’exploiter.

Récupérer des données - Selector

Nous allons désormais chercher à récupérer les données au sein d’un composant applicatif.

La solution la plus simple semble être de rajouter dans le Store une fonction permettant de récupérer l’état :

select(): Observable<ApplicationState> {
    return this.state$.asObservable();
  }

Et pour extraire la liste des tâches côté composants :

export class TodosListComponent {

  constructor(
    private store: Store
  ) { }

  todos$ = this.store.select().pipe(map(state => state.todos));
}

Cette solution fonctionne, mais n’est pas réellement optimum. Ici, à chaque fois que notre état change, notre observable va voir passer une nouvelle valeur et notre écran va se poser la question de rafraîchir. Nous pouvons sécuriser cela à l’aide de l’opérateur distinctUntilChanged qui signalera une nouvelle valeur seulement lors d’un changement. Nous obtenons le code suivant :

todos$ = this.store.select().pipe(map(state => state.todos), distinctUntilChanged());

Cette solution évite du traitement Angular inutile, mais risque de créer beaucoup de duplication : à chaque usage, nous allons utiliser map et distinctUntilChanged. Pour l’éviter, nous pouvons déplacer ce code dans le service :

export interface ApplicationState {
  todos: Todo[];
  user?: User;
}

@Injectable({
  providedIn: 'root',
})
export class Store {
  private state$ = new BehaviorSubject<ApplicationState>({
    todos: [],
  });

  select<T>(selector: (state: ApplicationState) => T): Observable<T> {
    return this.state$
      .asObservable()
      .pipe(map(selector), distinctUntilChanged());
  }
}

export class TodosListComponent {
  constructor(private store: Store) {}

  todos$ = this.store.select((state) => state.todos);
}

La méthode select prend maintenant un paramètre : une fonction, nommée selector, qui permet de choisir la donnée que l’on souhaite récupérer. La signature (state: ApplicationState) => T indique une fonction qui prend un état et qui renvoie une variable de type T (type générique).

Ce petit outil de récupération des données se nomme un “selector”. C’est l’un des concepts clef mis en place par Redux.

Emettre des actions - Action dispatching

La donnée est disponible et nous somme désormais capables de la consulter. La donnée n’est pas encore modifiable, son utilité est donc limitée. Dans cette architecture, la responsabilité de modifications des données incombe uniquement au store. Les composants communiquent avec le store via un système d’événement nommé “Action”.

Une action est un objet simple que les composants vont envoyer dans le Store. Cette action est identifiée par un “type”, un identifiant qui permet de reconnaître l’action. Une action peut également porter des données (par exemple, les données d’une nouvelle tâche).

Nous pouvons donc définir l’interface suivante :

export interface Action<T> {
  type: string;
  payload?: T;
}

L’envoi d’une action dans le système s’appelle un “dispatch”. Il s’agit en général d’une méthode mise à disposition par le store qui va prendre en charge le traitement de l’action.

Rajoutons cette méthode dans notre Store :

export interface Action<T> {
  type: string;
  payload?: T;
}

export interface ApplicationState {
  todos: Todo[];
  user?: User;
}

@Injectable({
  providedIn: 'root',
})
export class Store {
  private state$ = new BehaviorSubject<ApplicationState>({
    todos: [],
  });

  select<T>(selector: (state: ApplicationState) => T): Observable<T> {
    return this.state$
      .asObservable()
      .pipe(map(selector), distinctUntilChanged());
  }

  dispatch(action: Action) {
    // TODO - Traiter l'action
  }
}

Puis dans le composant, commençons par émettre une action au moment où l’on crée une nouvelle tâche :

export class TodosListComponent {
  constructor(private store: Store) {}

  todos$ = this.store.select((state) => state.todos);

  addTodo(todo: Todo) {
    this.store.dispatch({ type: 'Nouvelle todo', payload: { todo } });
  }
}

L’action est maintenant correctement émise. Il reste à indiquer au Store comment la traiter.

Modifier les données - Reducer

Pour traiter les actions, nous allons passer par les étapes suivantes :

  • Récupérer l’état courant

  • Appliquer les règles de gestions aux données

  • Mettre à jour l’état

dispatch(action: Action) {
  // Récupérer l'état
  let state = this.state$.value;

  // Appliquer l'action
  switch (action.type) {
    case 'Nouvelle todo':
      state.todos = [...state.todos, action.payload.todo];
  }

  // Mettre à jour l'état
  this.state$.next(state);
}

Ce code est fonctionnel mais pose quelques soucis. Il n’est pas dur d’imaginer que le switch va très vite grossir et perdre en lisibilité. Pour découper un peu ce code, nous allons donc introduire le concept de Reducer.

Un reducer est une fonction qui :

  • Aux paramètres suivants :

    • L’état actuel de l’application

    • L’action à traiter

  • Renvoie un état.

Ce qui donne l’interface suivante :

export type Reducer = (state: ApplicationState, action: Action) => ApplicationState;

Pour permettre le découpage du traitement, nous devons accepter de travailler avec plusieurs reducers. Dans notre Store, nous allons donc rajouter :

  • Une liste de reducers

  • Une méthode pour enregistrer un nouveau reducer

  private reducers: Reducer[] = [];

  registerReducer(reducer: Reducer) {
    this.reducers.push(reducer);
  }

Créons maintenant un reducer pour notre transformation et enregistrons-le via registerReducer :

store.registerReducer((state: ApplicationState, action: Action) => {
      switch (action.type) {
        case 'Nouvelle todo':
          return {
            ...state,
            todos: [...state.todos, action.payload.todo],
          };
        default:
          return state;
      }
    });

La fonction de dispatch va maintenant exécuter séquentiellement chaque reducer et transformer l’état “petit à petit”.

  dispatch(action: Action) {
    // Récupérer l'état
    let state = this.state$.value;

    // Appliquer l'action
    this.reducers.forEach((reducer) => {
      state = reducer(state, action);
    });

    // Mettre à jour l'état
    this.state$.next(state);
  }

Cette fonction semble assez peu performante de prime abord (passer en revue tous les reducers, avec tous les switch, à chaque action). C’est toutefois une fausse impression :

  • Le coût réel d’un switch reste très faible.

  • La majorité des reducers ne feront rien d’autre que renvoyer l’état non modifié.

Une fois l’état recalculé, nous mettons à jour le store en poussant la nouvelle valeur : c’est cette modification qui provoquera le rafraîchissement de l’application.

Nous avons introduit ici le concept de reducer qui vient rejoindre les concepts de bases :

  • State

  • Selector

  • Reducer

  • Action

Travailler avec l’asynchrone - Effects, Epics, …

Les reducers présentent tout de même une limitation : pour pouvoir être enchaînés ainsi, ils doivent être “synchrones”. Dans le cas contraire, on risquerait d’avoir des écrasements de données (le premier reducer qui, au moment où il s’exécute réellement, aurait un state potentiellement périmé). Nous sommes dans un cas d'exclusion mutuelle des différents reducers.

Ces actions asynchrones sont couramment appelées “Effet de bord”. Selon le framework utilisé, vous pourrez toutefois les trouver sous différentes appellations : Effect, Epic, … Le nommage diffère, mais le rôle et le fonctionnement sont identiques :

Un effect va commencer par écouter certaines actions, effectuer les traitements asynchrones nécessaires, puis émettre de nouvelles actions. En aucun cas un Effect ne doit modifier l’état : ce sont les reducers qui ont ce rôle.

Mettons en place un premier effect dont le rôle est, par exemple, d’appeler une API back pour créer la tâche. En premier lieu, nous allons avoir besoin de suivre les actions au fur et à mesure des dispatchs.

Pour cela :

  • Ajoutons un subject dans lequel sera envoyée chaque action. En souscrivant à cette observable, nous pourrons être notifiés.

  • Créons une fonction qui nous permette d’écouter les actions d’un type particulier.

export type Reducer = (
  state: ApplicationState,
  action: Action
) => ApplicationState;

@Injectable({
  providedIn: 'root',
})
export class Store {
  ...
  private actions$ = new Subject<Action>();

  ...

  listenActionOfType(type: string) {
    return this.actions$
      .asObservable()
      .pipe(filter((action) => action.type === type));
  }

  ...

  dispatch(action: Action) {
    ...
    this.actions$.next(action);
  }
}

Nous allons maintenant mettre en place un effect qui :

  • Va écouter les actions de création de nouvelles tâches

  • Emettre une action pour indiquer le succès de l’opération

  • Emettre une action pour indiquer l’échec de l’opération si besoin

this.listenActionOfType('Nouvelle todo').pipe(
  switchMap((action) => this.http.post('/todos', action.payload)),
  tap((todo) =>
    this.store.dispatch({ type: 'Nouvelle todo - Success', payload: { todo } })
  ),
  catchError(() => {
    this.store.dispatch({
      type: 'Nouvelle todo - Error',
    });
    return EMPTY;
  })
);

Si nous souhaitons mettre à jour l'état uniquement après validation du back-end, il me suffirait de connecter notre reducer sur l'action de succès :

this.registerReducer((state: ApplicationState, action: Action) => {
      switch (action.type) {
        case 'Nouvelle todo - Succèss':
          return {
            ...state,
            todos: [...state.todos, action.payload.todo],
          };
        default:
          return state;
      }
    });

Synthèse

Le code implémenté plus haut correspond au schéma suivant :

Utiliser ce code en production est à proscire. Ce store souffre d’un certain nombre de défauts que ce soit dans l’outillage totalement absent, ou dans des fonctionnalités manquantes (rien ne sécurise ici le dispatch de plusieurs actions en même temps). Toutefois, ce code un peu simpliste présente l’ensemble des concepts associés au Store.

Il reste maintenant une question cruciale : pourquoi ? Quelles sont les forces de cette architecture ? Même si cette dernière apporte son lot de complexité (une nouvelle technologie à apprendre, en plus d’une manière de structurer l’application), elle permet d’obtenir du code plus facile à maintenir. En découplant donnée et affichage, on obtient :

  • Des composants beaucoup plus simples, concentrés sur l’affichage et l’interaction utilisateur.

  • Une gestion de la donnée également simplifiée, et surtout plus facilement testable, expurgée de la partie “graphique”.

  • Une application plus aisée à faire grossir et évoluer au fil du temps.

L’outillage fourni autour des stores redux (et disponible pour la plupart des Frameworks) est également une vraie aide : grâce au Redux Dev Tool, on peut suivre l’évolution des données de nos applications, action après action, avec tout l’historique.

Cela ne se fait toutefois pas sans coût : la mise en place d’un store, et sa modification, demandent un apprentissage préalable. Pour moi, cette architecture est intéressante pour les grosses applications, surtout quand beaucoup de composants interagissent avec les mêmes données. Pour une application “simple”, il faut peut-être privilégier une architecture plus abordable (Keep it Simple and Stupide).

Pour aller plus loin

Si vous désirez en savoir davantage sur cette architecture et les différents frameworks disponibles, je vous conseille de vous référer :

  • À la documentation de Redux, le framework qui a popularisé le concept.

  • A la documentation du framework NGXS. Pour ceux qui sont dans l'ecosystème Angular, c'est le framework que je conseille car il offre une montée en compétence plus facile pour les équipes (et une documentation bien faite).

  • À la documentation officielle d'ELM. L'architecture des Stores est directement issue de l'architecture construit pour ce langage. Il s'agit donc d'une sorte de "retour aux sources".