NgRx SignalStore (le paquet @ngrx/signals) est une façon déclarative de construire des Stores au-dessus des Signals. Vous composez des features (withState, withComputed, withMethods, withHooks, withEntities…), et le résultat est une classe injectable. Son état est exposé sous forme de Signals et ne peut être modifié que par les méthodes que vous définissez.
C’est l’étape suivante après le Store artisanal de la page State Management : les mêmes idées, mais avec une structure standard, des entités prêtes à l’emploi, une intégration RxJS pour les flux asynchrones et des morceaux réutilisables d’un Store à l’autre. La version utilisée ici est @ngrx/signals 22, compatible avec Angular 22.
Anatomie d’un Store
Un signalStore(...) est une liste de features appliquées dans l’ordre. Chaque feature voit ce qu’ont ajouté les précédentes. withState définit l’état : chaque propriété devient un Signal (store.todos()), et les objets imbriqués deviennent des deep signals. withComputed ajoute l’état dérivé, withMethods ajoute les opérations. Seules les méthodes peuvent appeler patchState. L’état est protégé par défaut : impossible de le modifier directement depuis l’extérieur du Store.
import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
type Filter = 'all' | 'open' | 'done';
interface Todo { id: number; title: string; done: boolean }
export const TodoStore = signalStore(
{ providedIn: 'root' }, // sans l’option : mettez-le dans `providers` là où vous en avez besoin
withState({ todos: [] as Todo[], filter: 'all' as Filter }),
withComputed(({ todos, filter }) => ({
visible: computed(() =>
filter() === 'all' ? todos() : todos().filter((t) => t.done === (filter() === 'done')),
),
openCount: computed(() => todos().filter((t) => !t.done).length),
})),
withMethods((store) => ({
add(title: string): void {
patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
toggle(id: number): void {
patchState(store, (s) => ({ todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
},
setFilter(filter: Filter): void {
patchState(store, { filter });
},
})),
);patchState reçoit soit un objet partiel ({ filter }), soit une fonction (state) => Partial<State>. Il accepte aussi autant d’updaters que vous voulez, appliqués ensemble. L’état est immuable : vous renvoyez toujours de nouveaux objets et tableaux, sinon les Signals ne remarquent pas le changement (même règle que pour signal()).
Où vit le Store
Comme pour tout Service, la durée de vie d’un Store dépend de l’injector où vous le fournissez. Avec { providedIn: 'root' }, vous obtenez une seule instance globale, adaptée à la session utilisateur ou à un panier. Sans cette option, vous le mettez dans les providers d’un composant ou d’une route et vous obtenez une nouvelle instance, détruite avec lui. C’est le bon choix pour l’état d’une page.
// 1. Global : une seule instance pour toute l’application
export const SessionStore = signalStore({ providedIn: 'root' }, withState({ user: null as User | null }));
// 2. Par page / par composant : une nouvelle instance, détruite avec le composant
export const ProductsStore = signalStore(withEntities<Product>() /* ... */);
@Component({
providers: [ProductsStore], // chaque <app-catalog> a son propre Store
template: `...`,
})
export class Catalog {
readonly store = inject(ProductsStore);
}
// 3. Par route (feature) : vit aussi longtemps que la route
{ path: 'catalog', providers: [ProductsStore], loadComponent: () => import('./catalog') }Entités
withEntities<Product>() ajoute une collection normalisée : dans l’état, vous avez ids et entityMap, et côté Signal, entities(), un tableau dérivé. Les updaters de @ngrx/signals/entities (setAllEntities, addEntity, updateEntity, upsertEntity, removeEntity…) se passent directement à patchState. Pour plusieurs collections dans le même Store, il existe des collections nommées (withEntities({ entity: type<Product>(), collection: 'product' }) → productEntities()).
Flux asynchrones avec rxMethod
rxMethod (de @ngrx/signals/rxjs-interop) transforme un pipe RxJS en méthode. Vous pouvez l’appeler avec une valeur, un Observable ou un Signal. Avec un Signal, la méthode s’exécute à chaque changement de celui-ci. L’appel store.search(store.query) dans onInit lie la recherche à la requête une seule fois. Le reste, c’est du RxJS ordinaire : debounceTime, switchMap pour annuler la requête périmée, catchError à l’intérieur du switchMap, pour qu’une erreur n’arrête pas le flux (voir RxJS avancé). L’abonnement se ferme tout seul quand le Store est détruit.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { setAllEntities, updateEntity, withEntities } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { catchError, debounceTime, distinctUntilChanged, EMPTY, pipe, switchMap, tap } from 'rxjs';
export const ProductsStore = signalStore(
withEntities<Product>(), // état : ids + entityMap ; Signal : entities()
withState({ query: '' }),
withRequestStatus(), // feature personnalisée (ci-dessous)
withMethods((store, http = inject(HttpClient)) => ({
setQuery: (query: string) => patchState(store, { query }),
buy: (id: number) =>
patchState(store, updateEntity({ id, changes: (p) => ({ stock: p.stock - 1 }) })),
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, setPending())),
switchMap((q) =>
http.get<Page<Product>>(`/api/products?q=${encodeURIComponent(q)}`).pipe(
tap((page) => patchState(store, setAllEntities(page.items), setFulfilled())),
catchError(() => {
patchState(store, setError('Le serveur ne répond pas.'));
return EMPTY; // le flux externe reste vivant
}),
),
),
),
),
})),
withHooks({
onInit(store) {
store.search(store.query); // rxMethod reçoit un SIGNAL : elle s’exécute à chaque changement
},
}),
);tapResponse à la place de tap + catchError. Il fait la même chose de façon plus compacte, mais vient du paquet séparé @ngrx/operators. L’exemple ici n’utilise que RxJS.Features réutilisables
signalStoreFeature(...) regroupe plusieurs features dans une fonction que vous ajoutez à n’importe quel Store, par exemple withRequestStatus() : l’état de la requête plus isPending et error. Les updaters sont de simples fonctions qui renvoient un morceau d’état, vous pouvez donc les combiner avec d’autres dans le même patchState. Cela supprime le code loading/error copié-collé dans chaque Store.
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };
// Un morceau de Store réutilisable : état + computeds, composé dans n’importe quel signalStore
export function withRequestStatus() {
return signalStoreFeature(
withState<{ requestStatus: RequestStatus }>({ requestStatus: 'idle' }),
withComputed(({ requestStatus }) => ({
isPending: computed(() => requestStatus() === 'pending'),
error: computed(() => {
const s = requestStatus();
return typeof s === 'object' ? s.error : null;
}),
})),
);
}
// Updaters : de simples fonctions qui renvoient un morceau d’état → patchState(store, setPending())
export const setPending = () => ({ requestStatus: 'pending' as RequestStatus });
export const setFulfilled = () => ({ requestStatus: 'fulfilled' as RequestStatus });
export const setError = (error: string) => ({ requestStatus: { error } as RequestStatus });- Aucun produit.
{
"query": "",
"inStockOnly": false,
"cart": 0,
"requestStatus": "idle",
"ids": []
}Le reste de la boîte à outils
signalState est l’option « sans Store » : un objet de Signals pour l’état local d’un composant, toujours avec patchState. withProps ajoute des propriétés qui ne sont pas de l’état (services, resources, observables). withLinkedState fait pour un Store ce que linkedSignal fait pour un composant. getState donne un instantané de l’état, et watchState vous notifie de façon synchrone à chaque changement. Pour les tests, unprotected(store) de @ngrx/signals/testing permet de mettre le Store dans un état donné. Le paquet propose aussi un plugin d’événements (@ngrx/signals/events), pour l’approche façon Redux avec événements et reducers.
import { patchState, signalState } from '@ngrx/signals';
// Pour l’état local d’UN composant : pas de Store, juste un objet de Signals
@Component({ /* ... */ })
export class Filters {
readonly state = signalState({ query: '', page: 1, sort: 'name' as 'name' | 'price' });
next(): void {
patchState(this.state, (s) => ({ page: s.page + 1 }));
}
// dans le template : state.query(), state.page()
}Quand OUI, quand NON
- Un état de feature utilisé par plusieurs composants, avec des opérations asynchrones (recherche, enregistrement, pagination).
- Des collections d’entités mises à jour souvent (listes éditables, paniers, tableaux).
- Plusieurs équipes qui veulent la même forme pour tous les Stores, et des features réutilisables.
- Vous avez du NgRx Store classique et voulez quelque chose de plus léger pour les nouvelles features. Les deux peuvent coexister.
- L’état d’un seul composant :
signal()ousignalState. - Des données lues depuis le serveur et simplement affichées :
httpResource(voir resource). - Un petit Service avec deux Signals et trois méthodes : un Store artisanal est plus clair pour tout le monde.
- Des applications où personne dans l’équipe ne connaît NgRx et où l’échéance est demain. La dépendance a un coût d’apprentissage.
| Store artisanal | SignalStore | NgRx Store (Redux) | |
|---|---|---|---|
| Boilerplate | minimal | faible | important (actions, reducers, effects, selectors) |
| Structure standard | selon ce que décide l’équipe | oui | oui, très stricte |
| Entités | à écrire soi-même | withEntities | @ngrx/entity |
| Async | resource / RxJS | rxMethod, resource | effects |
| Idéal pour | petites features | la plupart des applications | flux complexes, audit d’événements |
Pièges fréquents
- Muter l’état (
s.todos.push(x)dans un updater). Le Signal reçoit la même référence et rien ne se met à jour. Renvoyez toujours de nouveaux tableaux et objets. providedIn: 'root'pour l’état d’une page. L’état « colle » d’une visite à l’autre et se mélange entre les instances du composant. Mettez le Store dans lesprovidersde la page.catchErrorplacé aprèsswitchMapdans unerxMethod. La première erreur ferme le flux et la recherche ne fonctionne plus. Placez-le dans le flux interne.- De la logique métier dans les composants, avec le Store utilisé comme simple « sac de données ». Les méthodes du Store sont faites pour cela.
- Un seul Store énorme pour toute l’application. Faites de petits Stores, par feature, et composez-les avec des features. C’est plus facile à tester et à charger en lazy.
effect()dans les composants pour synchroniser des Stores entre eux. PréférezwithComputedouwithLinkedState(voir effect()).
Aide-mémoire
signalStore(config?, ...features) config : { providedIn: 'root' } ou rien (→ providers: [Store])
withState({ ... }) état → Signals : store.x()
withComputed(store => ({ y: computed(...) }))
withMethods((store, dep = inject(X)) => ({ ... })) le seul endroit où l’on modifie l’état
withHooks({ onInit(store) {}, onDestroy(store) {} })
withProps / withLinkedState propriétés hors état / état lié (comme linkedSignal)
signalStoreFeature(...features) morceaux réutilisables : withRequestStatus(), withPagination()
patchState(store, partial | updater, ...) plusieurs updaters en un seul appel
getState(store) / watchState(store, fn) instantané / observateur synchrone de l’état
rxMethod<T>(pipe(...)) from @ngrx/signals/rxjs-interop — accepte une valeur, un Signal ou un Observable
withEntities<T>() + setAllEntities, addEntity, updateEntity, removeEntity, upsertEntity (@ngrx/signals/entities)
signalState({ ... }) état local dans un composant, sans Store
unprotected(store) de @ngrx/signals/testing — définir l’état directement dans les tests