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).
Un store, même léger, distingue trois choses. C'est la clé pour ne pas retomber dans le chaos :
| Rôle | Outil signal | Qui y touche |
|---|---|---|
| État (la vérité brute) | signal privé, modifiable | le service seul |
| Sélecteurs (vues dérivées) | computed / asReadonly() | tout le monde, en lecture |
| Actions (mutations) | méthodes publiques | appelées par les composants |
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.
PosteService v2On 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.
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.
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.
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(); }
}
$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).
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).
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.
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 :
| Situation | Choix raisonnable |
|---|---|
| État local à une feature, quelques entités, dérivations simples | Service à signals (ce qu'on vient de faire) |
| État partagé entre beaucoup de features, flux async complexes | NgRx SignalStore (même modèle, outillé) |
| Besoin de devtools, time-travel, undo/redo, audit strict des mutations | NgRx Store (Redux complet) |
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.
asReadonly() / computed et garder le signal modifiable privé ?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.
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).
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.