Architecture de state avec les signals
À retenir — Un service
@Injectablequi encapsule des signals privés et n'expose que des lectures (asReadonly,computed) constitue un store réactif simple, testable et sans dépendance externe.linkedSignalgère l'état local qui doit se réinitialiser selon une source.
Vous savez manipuler les signals dans un composant. Voyons maintenant comment structurer l'état d'une application : partager un état entre composants, le garder propre, et connaître les outils avancés (linkedSignal) avant les bonnes pratiques de production.
Le pattern « service de state à signals »
Le moyen le plus simple de partager un état réactif est un service providedIn: 'root'. On y applique le principe d'encapsulation : l'état mutable est privé, on n'expose que des accès en lecture et des méthodes qui décrivent les transitions.
import { Injectable, signal, computed, inject, ChangeDetectionStrategy, Component } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CompteurStore {
// 1. État privé, seul le store peut l'écrire.
private readonly _valeur = signal(0);
// 2. Lectures publiques : signal en lecture seule + valeurs dérivées.
readonly valeur = this._valeur.asReadonly();
readonly estPositif = computed(() => this._valeur() > 0);
// 3. API publique : des intentions, pas des setters bruts.
incrementer(): void {
this._valeur.update((n) => n + 1);
}
decrementer(): void {
this._valeur.update((n) => n - 1);
}
reinitialiser(): void {
this._valeur.set(0);
}
}
N'importe quel composant injecte le store et lit ses signals. Tous les consommateurs restent synchronisés automatiquement :
@Component({
selector: 'app-affichage',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>
Valeur partagée : <strong>{{ store.valeur() }}</strong>
@if (store.estPositif()) { <span>✅</span> }
</p>
`,
})
export class AffichageComponent {
readonly store = inject(CompteurStore);
}
@Component({
selector: 'app-root',
standalone: true,
imports: [AffichageComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>State partagé entre composants</h1>
<app-affichage />
<button (click)="store.decrementer()">−</button>
<button (click)="store.incrementer()">+</button>
`,
})
export class AppComponent {
readonly store = inject(CompteurStore);
}
Essayez en direct
Le bouton et l'affichage sont deux composants distincts reliés par le même store :
state-service
Ce patron couvre une grande partie des besoins. Pour des applications plus larges, des bibliothèques comme NgRx SignalStore formalisent la même idée (état + computed + méthodes) avec des outils supplémentaires (entités, effets, devtools).
Modéliser un état réaliste
Un store de production regroupe souvent plusieurs signals et expose des dérivations métier :
interface Filtre {
recherche: string;
seulementActifs: boolean;
}
@Injectable({ providedIn: 'root' })
export class ProduitsStore {
private readonly _produits = signal<Produit[]>([]);
private readonly _filtre = signal<Filtre>({ recherche: '', seulementActifs: false });
readonly produits = this._produits.asReadonly();
readonly filtre = this._filtre.asReadonly();
// Vue dérivée : la liste filtrée, recalculée seulement si besoin.
readonly produitsFiltres = computed(() => {
const { recherche, seulementActifs } = this._filtre();
const terme = recherche.trim().toLowerCase();
return this._produits()
.filter((p) => !seulementActifs || p.actif)
.filter((p) => p.nom.toLowerCase().includes(terme));
});
readonly nombre = computed(() => this.produitsFiltres().length);
definirRecherche(recherche: string): void {
this._filtre.update((f) => ({ ...f, recherche }));
}
basculerActifs(): void {
this._filtre.update((f) => ({ ...f, seulementActifs: !f.seulementActifs }));
}
}
L'interface ne consomme que produitsFiltres() et nombre() : toute la logique de filtrage est centralisée, testable et réactive.
linkedSignal : un état local lié à une source
Parfois, on a besoin d'un état modifiable par l'utilisateur qui doit pourtant se réinitialiser quand une donnée source change. Exemple : une liste d'options et l'option sélectionnée — si la liste change, la sélection doit revenir au premier élément.
Un signal simple ne suffit pas (il ignore la source) ; un computed non plus (il n'est pas modifiable). La réponse est linkedSignal (Angular 19) :
import { Component, signal, linkedSignal, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>Choix d'une taille</h1>
@for (option of options(); track option) {
<button
(click)="choix.set(option)"
[style.fontWeight]="choix() === option ? '700' : '400'"
>
{{ option }}
</button>
}
<p>Sélection : <strong>{{ choix() }}</strong></p>
<button (click)="remplacerOptions()">Remplacer la liste d'options</button>
`,
})
export class AppComponent {
readonly options = signal(['Petit', 'Moyen', 'Grand']);
// Modifiable comme un signal, MAIS réinitialisé dès que options() change.
readonly choix = linkedSignal(() => this.options()[0]);
remplacerOptions(): void {
this.options.set(['XS', 'S', 'M', 'L']); // → choix revient à 'XS'
}
}
Essayez en direct
Sélectionnez une taille, puis remplacez la liste : la sélection se réinitialise.
linked-signal
Tester un store à signals
Tester devient trivial : pas de marbles RxJS, on lit les signals directement.
import { TestBed } from '@angular/core/testing';
describe('CompteurStore', () => {
it('incrémente la valeur et reflète estPositif', () => {
const store = TestBed.inject(CompteurStore);
expect(store.valeur()).toBe(0);
expect(store.estPositif()).toBe(false);
store.incrementer();
expect(store.valeur()).toBe(1);
expect(store.estPositif()).toBe(true);
});
});
Bonnes pratiques de production
- Encapsulez : signal privé
_x, exposition viaasReadonly()/computed. Jamais deWritableSignalpublic. - Exposez des intentions, pas des setters :
ajouterAuPanier(produit)plutôt quepanier.set(...)côté composant. - Dérivez avec
computed, n'« empilez » pas des effects pour synchroniser des signals entre eux. - Immuabilité : toujours une nouvelle référence pour tableaux et objets.
OnPush+ signals dans tous les composants.linkedSignalpour l'état local dépendant d'une source ;resource()pour les données asynchrones (chapitre 7).- Pour les très grosses applications, évaluez NgRx SignalStore — mais le service maison suffit dans la majorité des cas.
En résumé
- Un service à signals (privé +
asReadonly/computed+ méthodes) est un store réactif simple et testable. - Centralisez la logique métier dans des
computed. linkedSignalgère l'état local qui se réinitialise selon une source.- Encapsulation, immuabilité,
OnPushet dérivation sont les piliers d'une architecture saine.
Vous avez parcouru tout le modèle réactif d'Angular : des trois primitives à une architecture de production. Le quiz final valide l'ensemble.