Maitrisez le state management

Vous avez séparé les intentions métier en façades et les implémentations techniques en services, vos façades fonctionnent à merveille. Dans l'exercice du chapitre précédent, vous avez implémenté des façades qui stockent leurs propres données ; selon la solution que vous avez implémentée, il est possible que vous ayez introduit des dépendances entre les façades — c'est le cas du corrigé de l'activité A vous de jouer, par exemple. Est-ce que vous vous êtes demandé si c'était une approche valable ?

Gérez le state

Moi oui. J'ai même failli modifier l'exemple pour que ce ne soit pas le cas, mais ça donne l'occasion parfaite pour montrer l'importance des solutions de state management.

Dans le corrigé de l'exemple de l'application précédente, lorsque ProductPageFacade charge le produit, elle fait ces appels :

this.recommendationsFacade.loadRecommendations(product.category);
this.wishlistFacade.selectProduct(productId);

Du coup, on a une façade qui en appelle deux autres, parce que lorsque son produit sélectionné change, les recommandations et la wishlist doivent aussi changer. Ça crée une dépendance qui rend floues les responsabilités des différentes façades. On a des appels "cachés" qui créent un souci de prévisibilité et de compréhension du codebase. On ne sait plus qui est propriétaire de quelle donnée. La logique métier est dispersée, et le risque de dépendances circulaires augmente considérablement.

Si votre application reste très simple, gérer le state dans vos façades peut être largement suffisant. Mais comment faire lorsque la complexité arrive ?

Un store fait maison

Une première étape consiste à simplement séparer le stockage des données (le store) de la façade, sans utiliser une library externe. Il s'agit simplement de créer un objet centralisé qui permet de lire et d'écrire le state de votre application : généralement, on maintiendra quand même la façade comme seul point de communication pour les components, et les façades continueront à communiquer avec les services, et seront également les seuls objets qui interagissent avec le store.

Si on regarde cette structure :

Schéma représentant un composant qui interagit avec une façade ProductPageFacade, laquelle centralise les appels vers deux autres façades : WishlistFacade et RecommendationsFacade.
Sans store

Et on le compare à la même chose avec un store fait maison :

Schéma illustrant un composant accédant directement à trois façades (WishlistFacade, ProductPageFacade, RecommendationsFacade), lesquelles interagissent avec un ProductStore centralisé.
Avec store

Les choses sont beaucoup plus claires comme ça ! Le  ProductStore  contient toutes les données relatives aux  Product  ; les façades peuvent lire dedans et réagir aux changements qui y arrivent ; pour le component, rien n'a changé, car il communique toujours avec les mêmes façades qui retournent toujours les mêmes données.

Pour implémenter ce genre de store, il suffit de prendre toutes les données actuellement stockées dans la façade et les déplacer dans un objet séparé.

Nous allons mettre de côté l'exemple de l'exercice précédent, car ce sera le sujet de l'exercice de ce chapitre. Du coup, regardons un exemple pour la gestion des notifications d'une application :

@Injectable({ providedIn: 'root' })
export class NotificationsStore {
  private _notifications = signal<Notification[]>([]);
  
  private _unreadCount = computed(() => 
    this._notifications().filter(n => !n.read).length
  );
  
  notifications(): Signal<Notification[]> {
      return this._notifications.asReadonly();
  }
  
  unreadCount(): Signal<number> {
      return this._unreadCount;
  }
  
  add(message: string, type: string): void {
    const notification: Notification = {
      id: crypto.randomUUID(),
      message,
      type,
      read: false
    };
    
    this._notifications.update(notifications => 
      [...notifications, notification]
    );
  }
  
  markAsRead(id: string): void {
    this._notifications.update(notifications =>
      notifications.map(n => 
        n.id === id ? { ...n, read: true } : n
      )
    );
  }
  
  remove(id: string): void {
    this._notifications.update(notifications =>
      notifications.filter(n => n.id !== id)
    );
  }
}

Ce store permet des opérations très simples : notez cependant qu'il emploie quand même des termes spécifiques au métier ! On n'a pas  updateNotification  , on a  markAsRead  .

Voici deux façades qui peuvent dépendre de ce store :

@Injectable()
export class NotificationsFacade {
  private store = inject(NotificationsStore);
  
  notifications(): Signal<Notification[]> {
      return this.store.notifications;
  }
  unreadCount(): Signal<number> {
      return this.store.unreadCount;
  }
  
  notify(message: string, type: string): void {
    this.store.add(message, type);
  }
  
  markAsRead(id: string): void {
    this.store.markAsRead(id);
  }
  
  dismiss(id: string): void {
    this.store.remove(id);
  }
}

@Injectable()
export class ProductsFacade {
  private notifications = inject(NotificationsFacade);
  private cart = inject(Cart);
  
  addToCart(productId: string): void {
    this.cart.addProductWithId(productId).pipe(
        tap(() => this.notifications.notify('Produit ajouté au panier', 'success')),
        catchError(() => {
            this.notifications.notify('Erreur lors de l\'ajout au panier', 'error');
            return EMPTY;
        })
    ).subscribe();
  }
}

Ces deux façades dépendent du même  NotificationStore  mais ne dépendent pas l'une de l'autre : on a une seule source de vérité.

Dans cet exemple de store fait maison, la façade  NotificationsFacade  est totalement transparente : elle ne simplifie rien, et on a seulement quelques petits changements de nom (notify et dismiss plutôt que add et remove).

Pourquoi ne pas utiliser directement le store en mettant ces noms métier ?

Deux raisons :

  • le couplage : si vous couplez vos components à votre store, vous vous empêchez de faire évoluer votre store le jour où vous en avez besoin

  • les tests : ajouter une dépendance à vos components, c'est ajouter une dépendance à gérer pour les tests — ça casse également l'utilisation des façades comme pivot point, car vous avez deux couches qui dépendent du store ; vous aurez donc besoin de tester le store depuis ces deux couches

Ces stores fait maison sont suffisants pour beaucoup de use cases, mais demandent beaucoup de travail manuel et peuvent mener à des incohérences entre contextes (les différents développeurs ne vont pas forcément implémenter les stores de la même manière. Regardons maintenant une library qui peut nous aider.

NgRx SignalStore

NgRx SignalStore est une solution moderne de state management créée par l'équipe NgRx, mais qui n'est pas "le NgRx" dont vous avez peut-être entendu parler (et dont nous parlerons un peu plus tard). C'est une library légère, basée sur les Signals, qui formalise exactement les patterns que l'on vient de voir.

type NotificationsState = {
  notifications: Notification[];
};

export const NotificationsStore = signalStore(
  { providedIn: 'root' },
  
  withState<NotificationsState>({
    notifications: []
  }),
  
  withComputed(({ notifications }) => ({
    unreadCount: computed(() => 
      notifications().filter(n => !n.read).length
    )
  })),
  
  withMethods((store) => ({
    add(message: string, type: string): void {
      const notification: Notification = {
        id: crypto.randomUUID(),
        message,
        type,
        read: false
      };
      patchState(store, {
        notifications: [...store.notifications(), notification]
      });
    },
    
    markAsRead(id: string): void {
      patchState(store, {
        notifications: store.notifications().map(n =>
          n.id === id ? { ...n, read: true } : n
        )
      });
    },
    
    remove(id: string): void {
      patchState(store, {
        notifications: store.notifications().filter(n => n.id !== id)
      });
    }
  }))
);

Ce n'est pas un cours sur NgRx SignalStore, mais pour comprendre comment fonctionne ce store, regardons déjà comment notre façade le consomme :

@Injectable()
export class NotificationsFacade {
  private store = inject(NotificationsStore);
  
  notifications(): Signal<Notification[]> {
      return this.store.notifications;
  }
  unreadCount(): number {
      return this.store.unreadCount;
  }
  
  notify(message: string, type: Notification['type']): void {
    this.store.add(message, type);
  }
  
  markAsRead(id: string): void {
    this.store.markAsRead(id);
  }
  
  dismiss(id: string): void {
    this.store.remove(id);
  }
}

En bref :

  • la fonction  signalStore  génère un morceau de state

  • withState  et  withComputed  génèrent des Signals qu'on peut lire

  • withMethods  permet de modifier le state (grâce à  patchState  )

Le résultat final au niveau de la façade est le même, mais les helpers SignalStore facilitent et formalisent la création de ces stores. On a moins de code à écrire (on n'a pas un tas de Signals  readonly  à manipuler), et il y a beaucoup de plugins qui permettent d'étendre SignalStore pour encore plus de puissance. Par exemple,  withEntities  permet de manipuler des collections d'entités avec des méthodes toutes faites.

Voici exactement le même store, en utilisant  withEntities  au lieu de withState pour les Notification :

export const NotificationsStore = signalStore(
  { providedIn: 'root' },
  
  withEntities<Notification>(),
  
  withComputed(({ entities }) => ({
    unreadCount: computed(() => 
      entities().filter(n => !n.read).length
    )
  })),
  
  withMethods((store) => ({
    add(message: string, type: Notification['type']): void {
      const notification: Notification = {
        id: crypto.randomUUID(),
        message,
        type,
        read: false
      };
      patchState(store, addEntity(notification));
    },
    
    markAsRead(id: string): void {
      patchState(store, updateEntity({ 
        id, 
        changes: { read: true } 
      }));
    },
    
    remove(id: string): void {
      patchState(store, removeEntity(id));
    }
  }))
);

Comme vous pouvez le constater, utiliser  withEntities  expose des méthodes comme  addEntity  ,  updateEntity  , et  removeEntity  , ce qui vous évite d'avoir à manipuler des tableaux à la main.

Dans tous les cas, utiliser SignalStore permet de faciliter, de formaliser, et d'uniformiser les stores simples : il y a une courbe d'apprentissage, mais l'outil est vite maitrisé.

Bon, maintenant on passe aux choses sérieuses…

NgRx : oui oui, lui

NgRx est la solution de state management la plus complète et la plus puissante de l'écosystème Angular, mais c'est aussi la plus complexe. Là où SignalStore vous donne une structure simple basée sur les signals, NgRx vous impose un pattern architectural strict basé sur quatre piliers : les actions, reducers, selectors, et effects.

Cette complexité a un coût : courbe d'apprentissage élevée, beaucoup plus de boilerplate, et une architecture excessive pour des cas d'usage simples. Elle apporte cependant des avantages uniques dans les contextes les plus complexes.

Regardons déjà de quoi est fait NgRx.

1 . Actions

Ce sont les événements dans votre application. Ces événements peuvent venir de l'utilisateur (clic sur un bouton, saisie dans un formulaire…), de l'application (un événement déclenché au bout d'un certain temps), de l'API (réponse avec données, erreur…). Chaque action a un type qui le définit et, en option, un payload. Voici quelques exemples : LoadProducts, AddItemToCart, LoginSuccess, LoadProductsFailed.

2. Reducers

Les reducers sont les seuls à avoir le droit d'écrire dans le store, que ce soit pour ajouter, modifier, ou supprimer des données. Lorsqu'on les définit, on spécifie quel(s) action(s) les déclenchent. Par exemple, l'action AddItemToCart pourrait avoir comme payload l'id de l'item à ajouter, et un reducer pourrait y réagir et ajouter l'id au panier qui y est stocké. L'action LoginSuccess pourrait contenir les informations de l'utilisateur authentifié, et le reducer déclenché insérerait ces informations dans le store.

3. Selectors

Les selectors sont les fenêtres du store : c'est seulement à travers eux qu'on peut lire les données du store. Un selector peut être simple (récupérer tous les produits du store ou dérivé (fournir le nombre total de produits). Il peut aussi être paramétrisé (récupérer le produit avec un id spécifique). Les selectors sont dynamiques : traditionnellement ce sont des Observables, mais ils sont aussi disponibles sous forme de Signals.

4. Effects

Les effects représentent les "effets secondaires" qui doivent être déclenchés par des actions : l'exemple canonique est d'appeler une API, mais ça peut être pour appeler n'importe quel service. Tout événement qui doit réagir à une action mais qui ne touche pas au store directement doit être implémenté dans un effect.

Voici le schéma que fournit l'équipe NgRx pour expliquer le cycle de vie :

Diagramme illustrant le cycle de vie de l’état dans NgRx : un Component déclenche une Action, traitée par un Reducer ou des Effects. Le Store est mis à jour et accessible via un Selector, les Effects interagissent avec des Services.
Le cycle de vie NgRx

Qu'est-ce qui se passe ici ? Commençons en bas à gauche :

  • un component émet une action vers la droite

  • branche 1 : cette action déclenche un reducer

    • le reducer met à jour le store

    • le selector émet la nouvelle valeur

    • le component reçoit la nouvelle valeur

  • branche 2 : cette action déclenche un effect

    • l'effect appelle un service pour parler à l'API

    • l'API répond, l'effect émet une nouvelle action

    • goto branche 1 !

Comme vous pouvez le constater, utiliser NgRx ajoute tout un tas de complexité. Même l'équipe NgRx conseille de commencer avec le SignalStore et de changer pour NgRx seulement si le SignalStore ne suffit plus. Voici quelques exemples de cas où vous pourriez en avoir besoin :

  • plusieurs équipes travaillent dans le même codebase et veulent des patterns stricts et prévisibles

  • les transitions et règles métiers sont complexes et/ou peuvent être sujets à des race conditions ou autres problèmes de concurrency

  • il y a des besoins de persistence et de réhydratation complexes

  • il faut pouvoir "remonter le temps", que ce soit pour le debugging, pour l'audit, ou pour des fonctionnalités comme le undo/redo

Comment choisir ?

Je suis d'accord avec l'équipe NgRx : commencez avec SignalStore, et faites l'upgrade à NgRx seulement si SignalStore ne fait plus l'affaire. Créer vos stores fait maison (ou stocker les données dans vos façades) peut suffire pour des cas de figure très simples.

Peu importe l'implémentation que vous choisissez, vous saurez que vous avez réussi vos stores si vous n'avez plus qu'une seule source de vérité pour vos données, si vos façades ne dépendent plus les unes des autres, et si vos components affichent les données à jour !

À vous de jouer

Contexte

Votre tech lead vous dit que pour homogénéiser le codebase, vous devez introduire des stores en plus des façades créées dans le chapitre précédent. Elle vous précise que les façades ne doivent plus dépendre directement les unes des autres.

Consigne

  • repartez de votre solution au chapitre précédent (ou utilisez la branche e-commerce-with-facades) et refactorisez pour y introduire des stores

  • pensez aux dépendances entre façades et comment vous pouvez les éliminer

  • une façade peut, cependant, dépendre de plusieurs stores — lorsque c'est le cas, les Signals qui dépendent de plusieurs stores seront conservés dans la façade (car il s'agit de règles métier)

Corrigé

En résumé

  • Quand plusieurs façades doivent partager et réagir aux mêmes données, introduire un store comme source unique de vérité résout les dépendances entre façades

  • Commencez avec des stores fait maison pour apprendre les concepts, puis adoptez NgRx SignalStore par défaut pour bénéficier de conventions standardisées et réduire le boilerplate

  • NgRx complet reste exceptionnel et réservé aux très grandes équipes ou aux besoins très spécifiques (audit trail, time-travel debugging, machines à états complexes) — SignalStore suffit pour 90% des applications

  • Les components communiquent uniquement avec les façades, les façades orchestrent la logique métier et interagissent avec les stores, et les stores gèrent l'état pur de l'application

Votre architecture est solide avec des façades bien structurées et des stores qui coordonnent vos données, il est temps de vous assurer que tout ce code fonctionne comme prévu : parlons des tests.

Et si vous obteniez un diplôme OpenClassrooms ?
  • Formations jusqu’à 100 % financées
  • Date de début flexible
  • Projets professionnalisants
  • Mentorat individuel
Trouvez la formation et le financement faits pour vous