Cours Angular · Module 10: Gestion d'état

Projet : Le trésor d'état

8 min de lecture
Dans cette leçon9

Bienvenue dans les souterrains du château d'Osaka. Tu connais déjà trois façons de tenir un trésor : une guilde avec des signals, SignalStore et le NgRx classique. Aujourd'hui, le Shogun te remet les clés.

L'Académie tient un registre des samouraïs dont trois écrans se servent en même temps : la liste du dojo, le compteur de l'en-tête et le formulaire de recrutement. Si chacun gardait sa propre copie, une nouvelle recrue apparaîtrait dans le formulaire alors que la liste et le compteur l'ignoreraient encore. Trois copies, ce sont trois vérités, et le trésor ne doit en avoir qu'une. Tu vas donc construire un seul endroit où tous les écrans lisent et par lequel ils signalent leurs changements. En chemin, tu utiliseras tout ce que ce module t'a appris.

Étape 1 : l'inventaire des compartiments

Tout trésor commence par un inventaire. Un samouraï, décrit par l'interface Samurai, est un objet avec un numéro id, un nom name et un clan clan. L'interface TreasuryState décrit quatre compartiments : la liste samurai, le filtre de clan clanFilter, le drapeau loading et le champ error pour la nouvelle d'un échec.

1interface Samurai {
2  id: number;
3  name: string;
4  clan: string;
5}
6
7interface TreasuryState {
8  samurai: Samurai[];
9  clanFilter: string;
10  loading: boolean;
11  error: string | null;
12}

La liste, le drapeau et l'erreur relèvent de l'état serveur vu dans la première leçon du module, et le filtre relève de l'état de l'UI. Une interface ne stocke rien et disparaît à la compilation : elle veille seulement à ce que rien d'étranger à l'inventaire n'entre dans le trésor. Lis string | null comme "du texte ou rien" : l'absence d'erreur, c'est null, pas une chaîne vide.

Étape 2 : le trésor à l'aube

Nous écrivons l'état initial dans une constante séparée, initialState, car il servira deux fois : dans la guilde et dans la version SignalStore.

1const initialState: TreasuryState = {
2  samurai: [],
3  clanFilter: '',
4  loading: false,
5  error: null
6};

À l'aube, le trésor est vide, et nous convenons de lire le filtre vide comme "tous les clans". L'annotation TreasuryState veille à ce que rien ne manque : supprime la ligne du champ error, et le compilateur protestera aussitôt. Il n'y a encore aucun samouraï - le coursier apportera le registre à la sixième étape.

Étape 3 : seul le trésorier a la clé

Le trésorier, c'est la guilde TreasuryStore. L'import couvre d'emblée tout ce dont nous aurons besoin : signal et computed du module des signaux de fumée, inject du module consacré aux guildes et le coursier HttpClient du module consacré aux coursiers. Le décorateur @Injectable avec l'option providedIn: 'root' désigne une seule guilde pour tout le royaume, et le mot private cache l'état au reste de l'application.

1import { Injectable, computed, inject, signal } from '@angular/core';
2import { HttpClient } from '@angular/common/http';
3
4@Injectable({ providedIn: 'root' })
5export class TreasuryStore {
6  private state = signal<TreasuryState>(initialState);
7}

Chaque écran reçoit le même trésor, pas une copie. Seul le trésorier a la clé : un composant qui écrit store.state.set(...) ne compilera pas. Pour l'instant, pourtant, le trésor n'a aucune fenêtre, donc personne ne peut rien y lire.

Étape 4 : des fenêtres pour les hérauts

Les hérauts sont là pour lire l'état, pas pour jeter des pièces. Ajoute dans la classe des fenêtres computed, chacune avec le mot readonly, comme dans la première leçon du module. Les trois premières montrent chacune un compartiment, et les deux dernières sont dérivées : count compte les samouraïs, et visibleSamurai garde le clan choisi ou, quand le filtre est vide, toute la liste - c'est ainsi qu'il faut lire la condition clan ? ... : ....

1  readonly samurai = computed(() => this.state().samurai);
2  readonly loading = computed(() => this.state().loading);
3  readonly error = computed(() => this.state().error);
4  readonly count = computed(() => this.samurai().length);
5  readonly visibleSamurai = computed(() => {
6    const clan = this.state().clanFilter;
7    return clan ? this.samurai().filter(s => s.clan === clan) : this.samurai();
8  });

Une fenêtre computed n'a ni set() ni update(), et readonly veille seulement à ce que personne ne remplace la fenêtre elle-même. Nous ne gardons dans l'état ni le nombre de samouraïs ni la liste filtrée : ce qui peut se calculer est calculé par le scribe, si bien que le compteur ne se désaccorde jamais de la liste. En prime, quand seul loading change, la fenêtre samurai renvoie le même tableau, et count ne se recalcule même pas.

Étape 5 : les ordres du trésorier

Les changements entrent dans le trésor par une seule porte : les actions de la guilde. Les actions addSamurai, removeSamurai et setClanFilter fonctionnent comme dans la première leçon du module : update() prend l'ancien état et renvoie un nouvel objet dans lequel ...s recopie tous les compartiments, et un seul est écrasé.

1  addSamurai(samurai: Samurai): void {
2    this.state.update(s => ({ ...s, samurai: [...s.samurai, samurai] }));
3  }
4
5  removeSamurai(id: number): void {
6    this.state.update(s => ({ ...s, samurai: s.samurai.filter(x => x.id !== id) }));
7  }
8
9  setClanFilter(clanFilter: string): void {
10    this.state.update(s => ({ ...s, clanFilter }));
11  }

C'est recopier un nouveau rouleau plutôt que raturer une ligne de l'ancien : les trois points et filter construisent toujours un nouveau tableau, et l'ancien objet d'état ne change pas d'une virgule. Résiste à la tentation de s.samurai.push(samurai), car c'est le piège du module des signaux de fumée : le tableau reste le même, et aucune fenêtre ne remarque le changement.

Étape 6 : un coursier pour le registre

L'action loadSamurai() lève le drapeau loading, efface l'ancienne erreur, envoie le coursier avec get() typé Samurai[] et inscrit le résultat à son retour. Le coursier entre dans la guilde grâce à inject(HttpClient), et subscribe() reçoit deux branches : next avec la liste rapportée et error avec l'échec, dont nous prenons le champ message.

1  private http = inject(HttpClient);
2
3  loadSamurai(): void {
4    this.state.update(s => ({ ...s, loading: true, error: null }));
5    this.http.get<Samurai[]>('/api/samurai').subscribe({
6      next: samurai => this.state.update(s => ({ ...s, samurai, loading: false })),
7      error: err => this.state.update(s => ({ ...s, loading: false, error: err.message }))
8    });
9  }

Les deux branches abaissent le drapeau, si bien que l'indicateur de chargement ne reste jamais bloqué. Un échec ne touche pas au compartiment samurai, donc la liste du chargement précédent reste à l'écran à côté du message. Inutile de nettoyer l'abonnement : le coursier rapporte une seule réponse, et le flux se ferme de lui-même.

Étape 7 : le trésor dans le dojo

Le premier destinataire est un composant. Tu connais le décorateur @Component, la boucle @for avec track et l'événement (click) depuis les modules précédents. Le champ store reçoit le trésor par inject(), le template lit les fenêtres avec des parenthèses, et un clic donne un ordre.

1@Component({
2  selector: 'app-dojo-roster',
3  template: `
4    <h2>Au trésor : {{ store.count() }}</h2>
5    @for (s of store.visibleSamurai(); track s.id) {
6      <button (click)="store.removeSamurai(s.id)">{{ s.name }}</button>
7    }
8  `
9})
10export class DojoRosterComponent {
11  readonly store = inject(TreasuryStore);
12}

Le composant n'a aucun état à lui, et pourtant le compteur compte tout le monde, la liste montre le clan choisi, et un clic congédie un samouraï dans les deux vues à la fois. Tu lances le chargement comme dans la leçon sur SignalStore, en appelant this.store.loadSamurai() dans le constructeur, et tu affiches le drapeau store.loading() avec un bloc @if. Le composant n'importe ni signal ni HttpClient, car tout le savoir sur l'état vit dans la guilde. Le compteur de l'en-tête et le formulaire de recrutement injecteront la même guilde.

Étape 8 : le même trésor en SignalStore

La tâche du lien te permet de choisir la seconde route, alors construisons TreasurySignalStore. Les imports sont ceux de la leçon sur SignalStore : signalStore, withState, withComputed, withMethods et patchState depuis @ngrx/signals, plus computed depuis @angular/core. L'état de départ est notre initialState.

1export const TreasurySignalStore = signalStore(
2  { providedIn: 'root' },
3  withState(initialState),
4  withComputed(store => ({
5    count: computed(() => store.samurai().length)
6  })),
7  withMethods(store => ({
8    addSamurai(samurai: Samurai): void {
9      patchState(store, state => ({ samurai: [...state.samurai, samurai] }));
10    }
11  }))
12);

Le bloc withState remplace le signal privé et ouvre de lui-même une fenêtre sur chaque compartiment, si bien que tu obtiens store.samurai() sans écrire de computed. Les fenêtres dérivées passent dans withComputed, les ordres dans withMethods, et patchState recopie pour toi les autres compartiments - le filtre se règle avec la ligne patchState(store, { clanFilter }). La règle reste la même : de l'extérieur, tu lis des fenêtres et tu appelles des méthodes. Tu trouveras la suite dans la documentation de NgRx SignalStore.

Quel trésor choisir

Je te recommande la guilde avec des signals : dans un petit trésor, elle montre à l'œil nu la clé, les fenêtres et les ordres. Passe à SignalStore quand tu auras plusieurs trésors et que tu te surprendras à répéter le même squelette. Laisse le NgRx classique aux grands royaumes, car lever une armée contre un seul registre est une cérémonie sans bénéfice.

Mets le projet sur StackBlitz ou dans un dépôt et soumets le lien dans la tâche suivante. Avant l'envoi, vérifie trois choses : l'état privé, les fenêtres computed et des actions qui créent un nouvel objet d'état. Dans le prochain module, L'art de la guerre, tu mettras le trésor à l'épreuve : il n'a pas de template, tu pourras donc le vérifier par un test sans dessiner le moindre écran.

Retiens ceci : le trésor a une seule clé et de nombreuses fenêtres - tu enfermes l'état dans un signal privé, tu le montres par computed, et tu ne le modifies que par des ordres qui recopient un nouveau rouleau au lieu de raturer l'ancien.

Tu as repéré une erreur dans cette leçon ?

Articles utiles