Pendant des années, NgRx s’est appuyé sur une triade actions–reducers–effects héritée de Redux. Robuste, mais verbeuse : un simple incrément demandait souvent une action, un type, un reducer et un sélecteur. L’arrivée des signals Angular a permis de repenser cette stack. NgRx Signal Store est le résultat : une API compacte, typée de bout en bout, qui s’écrit en quelques lignes pour des cas simples et qui s’étend par composition pour des cas complexes. Ce tutoriel construit pas à pas un store de gestion de produits avec recherche, filtres et chargement asynchrone, puis le compose via withFeature pour démontrer la modularité.
Prérequis
- Angular 17 minimum (18+ recommandé pour la stabilité complète des signals).
- Composants standalone activés dans votre projet.
- Une compréhension de base des signals :
signal(),computed(),effect(). - 30 à 45 minutes pour suivre le tutoriel et lancer chaque test.
Étape 1 — Installer NgRx Signals
La bibliothèque s’installe par le schematic dédié, qui ajoute la dépendance dans package.json et met à jour les imports. NgRx Signals est un package séparé de NgRx Store historique : vous pouvez les utiliser ensemble (Signal Store pour les nouveaux écrans, NgRx Store pour le code existant) ou choisir Signal Store seul.
ng add @ngrx/signals
La commande détecte votre version d’Angular et installe la version compatible. À la fin, vérifiez que @ngrx/signals figure dans vos dépendances. Aucun module à enregistrer dans app.config.ts : Signal Store fonctionne purement par injection de fonctions de fabrique, ce qui réduit la friction d’adoption. Lancez ng serve pour confirmer que le projet compile toujours.
Étape 2 — Créer un premier store avec withState
Un store NgRx Signals se compose de blocs nommés features. Chaque feature ajoute une capacité au store. La plus fondamentale est withState, qui définit la forme initiale des données. Le résultat est exposé sous forme de signals automatiquement, ce qui rend chaque propriété lisible et trackable.
import { signalStore, withState } from '@ngrx/signals';
type ProductState = {
products: Product[];
loading: boolean;
query: string;
};
const initialState: ProductState = {
products: [],
loading: false,
query: '',
};
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
);
Le store est désormais injectable n’importe où. Dans un composant, vous accédez aux valeurs via store.products(), store.loading() et store.query(). Chaque accès s’inscrit dans le graphe réactif : un template qui lit store.loading() sera automatiquement re-rendu quand l’état change. L’option providedIn: 'root' rend le store global ; vous pouvez aussi l’omettre pour un store local en déclarant simplement la classe dans le tableau providers du composant qui le consomme (providers: [ProductStore]), ce qui crée une instance par instance de ce composant.
Étape 3 — Ajouter des valeurs dérivées avec withComputed
Les valeurs dérivées (filtrage, comptage, transformation) gagnent à être centralisées dans le store plutôt qu’éparpillées dans les composants. withComputed joue ce rôle. Il reçoit le store en construction et retourne un objet de signals calculés. Chaque calcul s’exécute uniquement quand ses dépendances changent, ce qui évite tout re-calcul superflu.
import { signalStore, withState, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ products, query }) => ({
filtered: computed(() => {
const q = query().toLowerCase();
return products().filter(p => p.name.toLowerCase().includes(q));
}),
total: computed(() => products().length),
hasMatches: computed(() => products().length > 0),
})),
);
Dans le composant, lisez ces dérivés comme n’importe quel signal : store.filtered(), store.total(). Un changement de query ne re-calcule que filtered et hasMatches, pas total (qui ne dépend que de products). C’est cette granularité qui rend le store performant même avec une centaine de dérivations. Pour vérifier que la réactivité fonctionne, ouvrez DevTools Angular et observez le compteur de change detection : il ne devrait s’incrémenter que sur les composants qui lisent un signal effectivement modifié.
Étape 4 — Définir des actions avec withMethods
Là où NgRx traditionnel séparait actions et reducers, Signal Store unifie tout dans withMethods. Une méthode peut lire l’état, le muter via patchState, déclencher un side-effect asynchrone, et exposer le résultat. La syntaxe reste impérative et lisible — pas de switch sur un type d’action, pas de combine reducers à maintenir.
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(/* ... */),
withMethods((store, http = inject(HttpClient)) => ({
async load(): Promise<void> {
patchState(store, { loading: true });
const data = await firstValueFrom(http.get<Product[]>('/api/products'));
patchState(store, { products: data, loading: false });
},
setQuery(q: string): void {
patchState(store, { query: q });
},
remove(id: number): void {
patchState(store, { products: store.products().filter(p => p.id !== id) });
},
})),
);
Trois méthodes typiques d’un store CRUD. load() bascule l’état de chargement, appelle l’API, puis met à jour la liste — le tout en quelques lignes. setQuery illustre la simplicité d’une mutation directe. remove montre qu’on peut lire l’état courant (store.products()) pour calculer la prochaine valeur. Dans le composant, l’usage devient trivial : (click)="store.remove(p.id)". Pour confirmer le bon fonctionnement, ajoutez un console.log dans load() et observez qu’il n’est appelé qu’une fois même si plusieurs composants consomment store.products().
Étape 5 — Gérer des collections avec withEntities
Quand on manipule une liste d’entités avec opérations CRUD individuelles (ajouter, modifier, supprimer par id), le plugin @ngrx/signals/entities évite de réinventer la roue. Il fournit un adapter qui normalise la collection en { entities, ids }, ce qui rend les lookups par id en O(1) et les mises à jour partielles plus propres.
import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities } from '@ngrx/signals/entities';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { inject } from '@angular/core';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withEntities<Product>(),
withState({ loading: false }),
withMethods((store, http = inject(HttpClient)) => ({
async load() {
patchState(store, { loading: true });
const data = await firstValueFrom(http.get<Product[]>('/api/products'));
patchState(store, setAllEntities(data), { loading: false });
},
create(p: Product) { patchState(store, addEntity(p)); },
update(id: number, changes: Partial<Product>) { patchState(store, updateEntity({ id, changes })); },
remove(id: number) { patchState(store, removeEntity(id)); },
})),
);
withEntities<Product>() ajoute automatiquement deux signals à votre store : entities() (le tableau) et entityMap() (le lookup par id). Les helpers addEntity, removeEntity, updateEntity, setAllEntities retournent des patches que patchState applique. La version 20 de NgRx ajoute prependEntity pour insérer en tête de liste, utile pour des fils d’activité ou des journaux où les éléments récents passent en premier.
Étape 6 — Composer des features avec withFeature
Quand un store grossit, le risque est qu’il devienne un fourre-tout. NgRx Signal Store 20 introduit withFeature, une factory qui reçoit le store courant et permet de créer des features dépendantes du contexte. C’est l’outil de choix pour des préoccupations transversales : pagination, undo/redo, persistance locale, suivi analytique.
import { signalStore, withState, withMethods, patchState, withFeature } from '@ngrx/signals';
// Feature réutilisable : pagination
function withPagination(pageSize: number) {
return signalStoreFeature(
withState({ page: 1, pageSize }),
withMethods((store) => ({
next() { patchState(store, { page: store.page() + 1 }); },
prev() { patchState(store, { page: Math.max(1, store.page() - 1) }); },
})),
);
}
export const ProductStore = signalStore(
{ providedIn: 'root' },
withEntities<Product>(),
withFeature((s) => withPagination(20)),
);
La pagination devient une brique réutilisable. Sept lignes plus tard, vous pouvez la composer dans n’importe quel store qui en a besoin : produits, commandes, utilisateurs. withFeature garantit que la fonction reçoit bien le store en construction, ce qui permet d’écrire des features dépendantes des autres parties de l’état (par exemple : recalculer la page courante quand le filtre change). Pour valider, écrivez un test : créez deux stores qui composent withPagination et vérifiez qu’ils maintiennent leur état indépendant.
Étape 7 — Hooks de cycle de vie avec withHooks
Certaines initialisations ne se déclarent ni dans l’état initial ni dans une méthode appelée explicitement. Charger les données à l’instantiation du store, journaliser sa destruction, synchroniser avec un événement réseau : withHooks couvre ces besoins via onInit et onDestroy.
import { signalStore, withState, withMethods, patchState, withHooks } from '@ngrx/signals';
export const ProductStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods(/* ... */),
withHooks({
onInit(store) {
store.load();
},
onDestroy(store) {
console.log('ProductStore détruit');
},
}),
);
onInit s’exécute la première fois que le store est instancié — pour un store providedIn: 'root', c’est à la création du contexte d’injection. onDestroy s’exécute lors du teardown du contexte parent. Pour un store local à un composant, ces hooks suivent le cycle du composant, ce qui rend par exemple la persistance en local storage triviale : sauver dans onDestroy, restaurer dans onInit. Surveillez la console : le message « ProductStore détruit » doit apparaître quand vous quittez la page hébergeant un composant qui injecte le store local.
Étape 8 — Brancher le store sur un composant
L’utilisation du store dans un composant standalone est triviale : inject(ProductStore) et tout est disponible. Le template lit les signals directement, sans async pipe. Les méthodes sont appelées sur événement utilisateur. La réactivité est gérée par le change detection des signals Angular, ce qui rend le composant compatible avec le mode zoneless sans modification.
@Component({
selector: 'app-product-list',
standalone: true,
imports: [FormsModule],
template: `
<input [ngModel]="store.query()" (ngModelChange)="store.setQuery($event)" placeholder="Rechercher" />
@if (store.loading()) {
<p>Chargement…</p>
} @else {
@for (p of store.filtered(); track p.id) {
<article>
<h3>{{ p.name }}</h3>
<button (click)="store.remove(p.id)">Supprimer</button>
</article>
} @empty {
<p>Aucun produit trouvé.</p>
}
}
`,
})
export class ProductListComponent {
protected store = inject(ProductStore);
}
Aucune subscription RxJS à maintenir, aucun pipe async, aucun unsubscribe à gérer. Chaque lecture de signal s’enregistre dans le contexte du template et est libérée automatiquement quand le composant est détruit. C’est ce qui rend Signal Store particulièrement adapté au mode zoneless : la change detection n’a plus besoin d’un trigger global, chaque signal sait quel composant doit être marqué pour rendu.
Étape 9 — Tester un Signal Store
Tester un store NgRx Signals est plus simple que tester un store classique. Pas besoin de marbles RxJS ni de TestBed lourd pour la plupart des cas. Vous instanciez le store dans un contexte d’injection, déclenchez des méthodes et lisez les signals comme dans un composant.
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { patchState } from '@ngrx/signals';
import { ProductStore } from './product.store';
describe('ProductStore', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), ProductStore],
});
});
it('met à jour la query', () => {
const store = TestBed.inject(ProductStore);
store.setQuery('clavier');
expect(store.query()).toBe('clavier');
});
it('filtre les produits par query', () => {
const store = TestBed.inject(ProductStore);
patchState(store, { products: [
{ id: 1, name: 'Clavier mécanique' },
{ id: 2, name: 'Souris' },
]});
store.setQuery('clavier');
expect(store.filtered()).toHaveLength(1);
});
});
Le test injecte le store, manipule l’état, et vérifie le résultat — c’est tout. Pour les méthodes asynchrones, marquez le test async et utilisez await normalement. Aucune subscription à fermer, aucune action factice à dispatcher. Lancez ng test (Vitest depuis Angular 21) et constatez la vitesse d’exécution : un store de taille moyenne se teste en moins d’une seconde sur Vitest, contre plusieurs secondes avec Karma.
Étape 10 — Migrer depuis NgRx Store classique
Si vous avez un projet historique avec NgRx Store, la migration peut se faire progressivement. Aucun besoin de tout réécrire d’un coup. La stratégie qui fonctionne le mieux en production : Signal Store pour tous les nouveaux écrans, NgRx Store classique pour le code existant, avec une passerelle minimale pour partager certains états critiques (l’utilisateur connecté par exemple).
// Passerelle : exposer un état NgRx Store classique en signal
import { Store } from '@ngrx/store';
import { toSignal } from '@angular/core/rxjs-interop';
export const AuthBridge = signalStore(
{ providedIn: 'root' },
withState({ user: null as User | null }),
withHooks({
onInit(store, ngrxStore = inject(Store)) {
const user = toSignal(ngrxStore.select(selectUser), { initialValue: null });
effect(() => patchState(store, { user: user() }));
},
}),
);
Cette passerelle expose l’état de l’authentification (stocké dans NgRx Store historique) comme un signal lisible par n’importe quel Signal Store. La migration peut alors se faire écran par écran sur plusieurs sprints, sans casser l’existant. Quand l’ancien store devient minoritaire, supprimez-le et la dette technique s’évanouit progressivement.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| NG0203 outside injection context | inject() appelé hors injection context |
Utiliser runInInjectionContext() ou le paramètre par défaut de la factory |
| L’état ne se met pas à jour dans le template | Oubli des parenthèses sur le signal (store.query au lieu de store.query()) |
Toujours appeler le signal comme une fonction |
| Boucle infinie de re-calcul | Un effect qui mute son propre signal source |
Utiliser untracked() autour de la mutation |
patchState ne tient pas compte d’un update |
Objet muté en place au lieu d’une nouvelle référence | Toujours retourner un nouvel objet, jamais muter |
| Le store local persiste entre composants | Mauvaise option providedIn |
Retirer providedIn: 'root' pour un scope composant |
Adaptation aux contextes contraints
NgRx Signal Store présente un bénéfice net en taille de bundle. Le package historique @ngrx/store ajoutait environ 25-30 Ko gzippés ; @ngrx/signals ajoute moins de 10 Ko gzippés, et seules les features utilisées sont incluses (tree-shaking efficace). Pour une application mobile-first avec un budget bundle strict, c’est un facteur déterminant. La performance d’exécution suit la même logique : la propagation d’un changement traverse uniquement les composants qui lisent le signal modifié, là où l’ancien store déclenchait potentiellement plusieurs sélecteurs et un cycle complet de change detection sur l’arbre.
FAQ
Faut-il connaître Redux pour utiliser NgRx Signal Store ?
Non. Signal Store s’inspire de Flux mais ne reprend ni les actions, ni les reducers explicites. Si vous savez écrire un service Angular avec des signals, vous êtes prêt.
Signal Store remplace-t-il complètement NgRx Store classique ?
Pour les nouveaux projets, oui dans 90 % des cas. Pour des applications qui ont besoin de l’écosystème Redux DevTools, des effets RxJS sophistiqués ou d’un journal d’actions complet, NgRx Store classique reste pertinent. La migration progressive est conseillée.
Comment partager un Signal Store entre plusieurs modules lazy-loaded ?
Avec providedIn: 'root', le store est instancié une seule fois et partagé. Si vous voulez un store par feature module, retirez providedIn: 'root' et déclarez-le dans les providers du composant racine de la feature.
Le plugin Events expérimental de NgRx v20 est-il à utiliser en production ?
Pas encore. Marqué expérimental, son API peut changer avant la stabilisation. Pour un projet en production, gardez l’approche par méthodes ; explorez Events sur un POC pour évaluer la valeur ajoutée.
Comment persister un Signal Store entre les sessions ?
Utilisez withHooks avec onInit pour restaurer depuis localStorage et un effect qui sauve à chaque changement. Une autre option est d’écrire une feature withPersistence(key) réutilisable via withFeature.
Pour aller plus loin
- Angular pour entreprise : guide pratique frontend 2026 donne le panorama dans lequel s’insère le state management.
- Angular signals et RxJS en pratique détaille les primitives signal/computed/effect que Signal Store utilise.
- Angular architecture modulaire explique comment placer un store dans une organisation feature-first.