
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 ?
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 ?
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 :

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

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 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 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.
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.
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.
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.
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 :

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
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 !

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.
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)
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.