Leçon 12 · Phase 2 — Structure d'application État · ~18 min

Services & gestion d'état : le store à signals

La leçon 11 a mis l'état des postes dans un service. Mais un signal public au milieu d'un service, ce n'est pas encore une gestion d'état : n'importe qui peut le muter, la logique fuit dans les composants, les dérivations se dupliquent. Cette leçon transforme PosteService en un vrai store léger : état privé, sélecteurs dérivés, actions — une seule porte d'écriture. Et on répond à la question que tout lead se pose : quand ça suffit, et quand sortir l'artillerie (NgRx).

Pourquoi ça compte pour Alliance Un écran ERP, c'est de l'état partout : la liste des postes, le filtre actif, l'élément sélectionné, l'état de chargement, les erreurs API. Éparpillé dans les composants, ça devient vite un plat de spaghetti — deux endroits qui modifient la même donnée, un filtre qui se désynchronise, un « ça marche chez moi ». Un store centralisé remet de l'ordre : une source de vérité, des mutations tracées à un seul endroit, des composants qui ne font qu'afficher. C'est aussi ce qui rend un écran testable et compréhensible par le reste de l'équipe.

1. Trois rôles à séparer : état, sélecteurs, actions

Un store, même léger, distingue trois choses. C'est la clé pour ne pas retomber dans le chaos :

RôleOutil signalQui y touche
État (la vérité brute)signal privé, modifiablele service seul
Sélecteurs (vues dérivées)computed / asReadonly()tout le monde, en lecture
Actions (mutations)méthodes publiquesappelées par les composants
Analogie .NET C'est du CQRS allégé : les commandes (actions) sont les seules à écrire, les requêtes (sélecteurs) ne font que lire. Ou, si tu connais Redux : state privé, selectors = computed, actions = tes méthodes. La différence avec un Redux classique : pas de boilerplate, pas de reducers, pas de dispatch. Les signals te donnent 80 % de la valeur d'un store pour 5 % du code.

2. Le store en pratique — PosteService v2

On reprend le service de la leçon 11 et on le structure. L'état devient entièrement privé ; on n'expose que des sélecteurs en lecture seule et des actions.

import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

type Statut = 'idle' | 'chargement' | 'prêt' | 'erreur';

@Injectable({ providedIn: 'root' })
export class PosteService {
  private http = inject(HttpClient);

  // ── ÉTAT : privé, seul le service écrit ──
  private _postes = signal<Poste[]>([]);
  private _filtre = signal<'tous' | 'actifs'>('tous');
  private _statut = signal<Statut>('idle');

  // ── SÉLECTEURS : dérivés, exposés en lecture seule ──
  readonly statut = this._statut.asReadonly();
  readonly postes = computed(() =>
    this._filtre() === 'actifs'
      ? this._postes().filter(p => p.actif)
      : this._postes());          // la liste filtrée, calculée UNE fois
  readonly nbPostes = computed(() => this.postes().length);

  // ── ACTIONS : les seules portes d'écriture ──
  charger() {
    if (this._statut() === 'prêt' || this._statut() === 'chargement') return; // idempotent : un seul fetch
    this._statut.set('chargement');
    this.http.get<Poste[]>('/api/postes').subscribe({
      next: list => { this._postes.set(list); this._statut.set('prêt'); },
      error: () => this._statut.set('erreur'),
    });
  }

  definirFiltre(f: string) {                 // l'action valide l'entrée à la frontière
    this._filtre.set(f === 'actifs' ? 'actifs' : 'tous');
  }
}

Regarde ce que ça donne : le filtrage n'est écrit qu'une fois (le computed postes), l'état de chargement est explicite (un signal statut, pas trois booléens éparpillés), et aucun composant ne peut écrire dans _postes ou _filtre — ils passent forcément par une action.

« Et ce subscribe, il ne fuit pas ? » — la bonne question La leçon 10 martelait : évite le subscribe manuel, sinon fuite d'abonnement. Ici on subscribe quand même, et c'est correct — deux raisons. (1) On est dans un service, pas un template : pas d'async pipe sous la main. Un Observable est paresseux, il faut bien l'abonner pour que la requête parte et pour pousser le résultat dans les signals. (2) Surtout : un http.get() émet une valeur puis se complete, et un flux qui se complète libère son abonnement tout seul — pas de fuite, même sans unsubscribe. La fuite de la leçon 10 vise les flux infinis (un Subject, un interval, le valueChanges d'un formulaire) qui n'émettent jamais de complete. Règle affinée : unsubscribe / takeUntilDestroyed obligatoire pour un flux infini, facultatif pour un one-shot HTTP. Quand on gère quand même un abonnement HTTP, c'est en général pour l'annuler (le switchMap de la leçon 09), pas contre une fuite.
« Un enum plutôt que ce | répété ? » Réflexe C# légitime — mais en TypeScript on évite l'enum : il génère du code à l'exécution, sa variante const enum est incompatible avec les bundlers d'Angular (isolatedModules), et ce n'est pas idiomatique. Deux alternatives. (a) L'union nue (type Statut = 'idle' | …) : la plus légère, zéro runtime, mais tu répètes la string à l'usage — parfait pour 2-3 valeurs locales. (b) Dès que ça pèse, l'objet as const + type dérivé, le vrai pont depuis l'enum : autocomplétion, une seule source de vérité, et le type reste une union de strings dessous (lisible dans les logs et l'API, tree-shakable).
const Statut = {
  Idle: 'idle', Chargement: 'chargement', Pret: 'prêt', Erreur: 'erreur',
} as const;
type Statut = typeof Statut[keyof typeof Statut]; // 'idle' | 'chargement' | 'prêt' | 'erreur'

this._statut.set(Statut.Chargement); // se lit comme un enum, mais c'est une union de strings

Astuce : const Statut (la valeur) et type Statut (le type) peuvent porter le même nom — TypeScript les range dans deux espaces distincts. Le reste de la leçon garde l'union nue pour rester lisible, mais retiens le pattern as const dès qu'un jeu de valeurs se répète.

3. Le composant devient « bête » (et c'est le but)

Une fois l'état et la logique dans le store, le composant se réduit à : lire des sélecteurs, appeler des actions. Il ne décide plus rien.

@Component({
  selector: 'app-liste-postes',
  standalone: true,
  template: `
    <select #filtre (change)="store.definirFiltre(filtre.value)">
      <option value="tous">Tous</option>
      <option value="actifs">Actifs</option>
    </select>

    @switch (store.statut()) {
      @case ('chargement') { <p>Chargement…</p> }
      @case ('erreur')     { <p>Erreur de chargement.</p> }
      @default {
        <p>{{ store.nbPostes() }} poste(s)</p>
        <ul>
          @for (p of store.postes(); track p.id) { <li>{{ p.nom }}</li> }
        </ul>
      }
    }
  `,
})
export class ListePostesComponent {
  protected store = inject(PosteService);
  constructor() { this.store.charger(); }
}
Éviter le $any($event.target) Le #filtre sur le <select> est une variable de référence de template : Angular la type précisément (HTMLSelectElement), donc filtre.value est un string connu. Plus besoin d'aller pêcher .target sur l'événement (typé EventTarget | null, sans .value) — c'est ce détour qui imposait le $any(...), un cast qui désactive la vérification de type. Réflexe de lead : quand un #ref fait l'affaire, préfère-le au $any, tu gardes le type-check. Et comme .value ressort en string brut, c'est l'action qui rétrécit vers 'tous' | 'actifs' à la frontière, pas le template.

Zéro logique métier dans le composant : il branche des sélecteurs (store.postes(), store.statut()) au template et délègue les intentions (definirFiltre) au store. Un deuxième écran qui affiche les mêmes postes injecte le même service et voit le même état, filtre compris — gratuitement (leçon 11).

Mutations immuables — le réflexe à garder Dans les actions, on remplace l'état, on ne le bricole pas en place. Ajouter une note : this._postes.update(liste => liste.map(p => p.id === id ? { ...p, note } : p)), pas un push ou une mutation d'objet. Comme avec les records immuables en C# : on produit une nouvelle valeur, le signal détecte le changement, les computed se recalculent. Muter en place = le signal ne « voit » rien, l'UI ne bouge pas (bug classique).
« Deux composants appellent charger() → deux fois le même fetch ? » Bonne prise. providedIn: 'root' garantit un seul service, pas un seul appel : deux constructeurs = deux charger(). La parade n'est pas de charger dans le constructeur du service (I/O dès l'instanciation, timing non maîtrisé, service pénible à tester — comme un constructeur .NET qu'on garde léger). On rend plutôt l'action idempotente : la garde if (this._statut() === 'prêt' || … 'chargement') return ignore les appels redondants — un seul fetch, peu importe le nombre d'appelants (l'équivalent d'un Lazy<T>). Cran au-dessus, côté archi : ne déclencher qu'à un seul endroit — un composant conteneur de la feature, ou un resolver de route (leçon 13) — et laisser les composants d'affichage purement passifs, sans charger() dans leur constructeur.

4. Quand ça suffit, quand sortir NgRx

Ce store à signals couvre l'écrasante majorité des écrans ERP. La question de lead n'est pas « quel framework d'état ? » mais « ai-je vraiment besoin de plus ? ». Repères :

SituationChoix raisonnable
État local à une feature, quelques entités, dérivations simplesService à signals (ce qu'on vient de faire)
État partagé entre beaucoup de features, flux async complexesNgRx SignalStore (même modèle, outillé)
Besoin de devtools, time-travel, undo/redo, audit strict des mutationsNgRx Store (Redux complet)
Œil de tech lead (1) N'expose jamais le signal modifiable : asReadonly() ou computed en public, le signal brut en privé. Une seule porte d'écriture (les actions) = tu sais toujours qui a changé l'état. (2) Toute dérivation va dans un computed du store, jamais recalculée dans chaque composant (duplication + risque de divergence). (3) Modélise le chargement par un statut à valeurs explicites, pas par isLoading + hasError + isEmpty qui peuvent se contredire. (4) Ne cargo-culte pas NgRx « parce que c'est pro ». Sur un état simple, c'est du boilerplate qui masque l'intention. Commence en service-signals ; migre vers SignalStore le jour où la douleur est réelle — c'est le même modèle mental, la bascule est douce.

5. Vérifie ta compréhension

Pourquoi exposer l'état via asReadonly() / computed et garder le signal modifiable privé ?
Deux composants affichent la liste filtrée des postes. Où placer le filtrage ?
Sur un écran à état simple (quelques entités, filtre, chargement), quand introduire NgRx ?
À retenir Un store à signals, c'est un service qui sépare trois rôles : état (signal privé, modifiable), sélecteurs (computed / asReadonly() publics), actions (méthodes, seules à écrire). Bénéfices : une source de vérité, une seule porte d'écriture, des dérivations calculées une fois, des composants « bêtes » qui affichent. Réflexes de lead : ne jamais exposer le signal modifiable, tout dériver en computed, modéliser le chargement par un statut explicite, muter en immuable. Et ne sortir NgRx que quand la complexité l'exige — le service à signals couvre le gros des écrans.
Source primaire à lire (10 min)

Angular — Signals (relire computed avec l'œil « sélecteur ») puis Creating an injectable service. Pour voir la version outillée du même modèle : NgRx SignalStore (survol, pour situer quand ça vaut le coup).

Je suis ton prof — pose-moi des questions. On a un vrai store maintenant. Tu veux qu'on ajoute une action sauverNote en optimistic update (on met à jour la liste localement, on branche l'appel serveur avec le switchMap de la leçon 09, et on annule si erreur), ou qu'on écrive le test du store en fournissant un faux HttpClient ? Demande-moi, ici, maintenant.
◀ 11 · Injection de dépendances Angular Suivant : 13 · Routing standalone ▶