SignalStore (@ngrx/signals) — store réactif natif signals
TL;DR
@ngrx/signalsréinvente le store NgRx autour des signals Angular : pas d'actions, pas de reducers, pas d'observables obligatoires. On déclare un store viasignalStore(...)en composant des features (withState,withComputed,withMethods,withHooks) qui s'empilent comme des mixins typés. Le state devient un signal lisible, les valeurs dérivées sont descomputed, et les opérations async passent parrxMethod. Le résultat : 80% moins de boilerplate que NgRx classic, integration zoneless native, et une API mentalement plus proche d'un service Angular que d'un store Redux. En 2026, c'est le défaut recommandé pour tout nouveau projet — sauf si DevTools time-travel ou middleware Redux sont indispensables.
🧠 Mental model — ASCII + analogie
Un SignalStore est un service Angular injectable qui expose un state observable nativement (via signals) et des méthodes qui le mutent. La différence avec un service classique : il est composé de features réutilisables, chaque feature ajoute des slots dans le store (state, computed, méthodes, hooks). C'est exactement l'idée des mixins ou des traits en POO, mais 100% type-safe et tree-shakable.
┌────────────────────────────────────────────────────────────┐
│ SignalStore Composition │
└────────────────────────────────────────────────────────────┘
signalStore( ┌──────────────────────────┐
{ providedIn: 'root' }, │ STORE FINAL TYPÉ │
withState({...}), ───────────────▶│ ┌────────────────────┐ │
withComputed(...), ───────────────▶│ │ state: Signal<T> │ │
withMethods(...), ───────────────▶│ │ computed: Signal<U> │ │
withHooks({...}), ───────────────▶│ │ methods(): void │ │
withMyCustomFeature(), ───────────────▶│ │ rxMethod(input$) │ │
) │ └────────────────────┘ │
└──────────────────────────┘
│
┌───────────▼──────────────┐
│ Composant consommateur │
│ store.users() │
│ store.loadUsers() │
└──────────────────────────┘L'analogie qui marche bien : un SignalStore est à un store Redux ce qu'un composant standalone est à un NgModule. Même finalité (encapsuler du comportement et de l'état), mais une API directe, sans cérémonie, où la composition remplace la configuration. On n'écrit plus « LOAD_USERS → reducer → state », on écrit loadUsers() { patchState(this, { loading: true }) }. Le mental model est celui d'un objet, pas d'un bus d'événements.
🛠️ Code minimal (ts + html)
Un SignalStore complet pour gérer une liste de tâches.
// todos.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { TodosApi } from './todos.api';
export interface Todo {
readonly id: string;
readonly title: string;
readonly done: boolean;
readonly priority: 'low' | 'medium' | 'high';
}
interface TodosState {
todos: Todo[];
filter: 'all' | 'active' | 'done';
loading: boolean;
error: string | null;
}
const initialState: TodosState = {
todos: [],
filter: 'all',
loading: false,
error: null,
};
export const TodosStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ todos, filter }) => ({
filteredTodos: computed(() => {
const f = filter();
const list = todos();
if (f === 'active') return list.filter((t) => !t.done);
if (f === 'done') return list.filter((t) => t.done);
return list;
}),
activeCount: computed(() => todos().filter((t) => !t.done).length),
doneCount: computed(() => todos().filter((t) => t.done).length),
})),
withMethods((store, api = inject(TodosApi)) => ({
setFilter(filter: TodosState['filter']) {
patchState(store, { filter });
},
toggle(id: string) {
patchState(store, (state) => ({
todos: state.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
}));
},
add(title: string) {
const todo: Todo = { id: crypto.randomUUID(), title, done: false, priority: 'medium' };
patchState(store, (state) => ({ todos: [todo, ...state.todos] }));
},
remove(id: string) {
patchState(store, (state) => ({ todos: state.todos.filter((t) => t.id !== id) }));
},
loadAll: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap(() =>
api.list().pipe(
tap({
next: (todos) => patchState(store, { todos, loading: false }),
error: (err: Error) => patchState(store, { loading: false, error: err.message }),
}),
),
),
),
),
})),
withHooks({
onInit(store) {
store.loadAll();
},
onDestroy() {
// cleanup éventuel
},
}),
);Consommation dans un composant standalone — c'est exactement comme un service.
// todos.page.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TodosStore } from './todos.store';
@Component({
selector: 'app-todos-page',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header>
<input #title placeholder="Nouvelle tâche" (keydown.enter)="add(title.value); title.value = ''" />
<select [value]="store.filter()" (change)="store.setFilter($any($event.target).value)">
<option value="all">Toutes</option>
<option value="active">Actives</option>
<option value="done">Terminées</option>
</select>
</header>
@if (store.loading()) {
<p>Chargement…</p>
}
@if (store.error(); as err) {
<p class="error">{{ err }}</p>
}
<ul>
@for (todo of store.filteredTodos(); track todo.id) {
<li>
<input type="checkbox" [checked]="todo.done" (change)="store.toggle(todo.id)" />
<span [class.done]="todo.done">{{ todo.title }}</span>
<button (click)="store.remove(todo.id)">×</button>
</li>
} @empty {
<li>Aucune tâche.</li>
}
</ul>
<footer>
{{ store.activeCount() }} actives, {{ store.doneCount() }} terminées
</footer>
`,
})
export class TodosPage {
protected readonly store = inject(TodosStore);
add(title: string) {
if (title.trim()) this.store.add(title.trim());
}
}Un exemple avec withEntities (équivalent SignalStore de createEntityAdapter).
// products.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks, patchState } from '@ngrx/signals';
import { withEntities, addEntity, removeEntity, updateEntity, setAllEntities } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { ProductsApi } from './products.api';
export interface Product {
id: string;
name: string;
price: number;
category: string;
}
export const ProductsStore = signalStore(
{ providedIn: 'root' },
withEntities<Product>(),
withState({ loading: false, selectedCategory: 'all' }),
withComputed(({ entities, selectedCategory }) => ({
filteredProducts: computed(() => {
const cat = selectedCategory();
const all = entities();
return cat === 'all' ? all : all.filter((p) => p.category === cat);
}),
categories: computed(() => [...new Set(entities().map((p) => p.category))]),
})),
withMethods((store, api = inject(ProductsApi)) => ({
add(product: Product) { patchState(store, addEntity(product)); },
update(id: string, changes: Partial<Product>) { patchState(store, updateEntity({ id, changes })); },
remove(id: string) { patchState(store, removeEntity(id)); },
setCategory(cat: string) { patchState(store, { selectedCategory: cat }); },
load: rxMethod<void>(
pipe(
tap(() => patchState(store, { loading: true })),
switchMap(() =>
api.list().pipe(
tap({
next: (products) => patchState(store, setAllEntities(products), { loading: false }),
error: () => patchState(store, { loading: false }),
}),
),
),
),
),
})),
);Composition de features réutilisables avec signalStoreFeature.
// features/with-pagination.ts
import { signalStoreFeature, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
export function withPagination<T>(pageSize = 20) {
return signalStoreFeature(
withState({ page: 1, pageSize }),
withComputed(({ page, pageSize }) => ({
offset: computed(() => (page() - 1) * pageSize()),
})),
withMethods((store) => ({
nextPage() {
patchState(store, (s) => ({ page: s.page + 1 }));
},
prevPage() {
patchState(store, (s) => ({ page: Math.max(1, s.page - 1) }));
},
goToPage(page: number) {
patchState(store, { page });
},
})),
);
}
// utilisation
export const UsersStore = signalStore(
{ providedIn: 'root' },
withState({ users: [] as User[] }),
withPagination(25),
withMethods((store) => ({
/* methods spécifiques utilisateurs */
})),
);🎯 Patterns courants
patchState avec updater fonctionnel. Pour toute mutation qui dépend de l'état courant, on passe une fonction (state) => Partial<State>. C'est plus sûr que de lire le signal puis de re-patcher : patchState(store, (s) => ({ count: s.count + 1 })) est atomique et ne souffre pas de race conditions lors d'appels successifs.
rxMethod pour les flux async. Pour les opérations qui nécessitent du flow control (debounce, switchMap, retry), rxMethod enveloppe une pipeline RxJS et expose une méthode appelable avec une valeur, un Observable ou un Signal. Il gère le subscribe/unsubscribe via le DestroyRef du contexte d'injection. C'est l'équivalent moderne d'un effect, mais collocalisé dans le store.
Composition par signalStoreFeature. Toute logique réutilisable (pagination, undo/redo, persistance localStorage, polling) se factorise dans une feature. Les features s'empilent dans l'ordre, et chaque feature voit l'état accumulé des précédentes. C'est ce qui rend le SignalStore profondément différent : on assemble un store comme on assemble des LEGO typés.
Stores root vs stores scope-route. Un store { providedIn: 'root' } est singleton. Pour des stores liés à une route (un wizard, une feature lazy-loadée), on l'enregistre dans les providers de la route ou du composant : sa durée de vie est celle de l'arbre d'injection. Cela évite les fuites de state entre navigations.
Signals dérivés via withComputed. Tout calcul à partir du state passe par un computed dans withComputed. Cela mémoïse automatiquement : tant que les inputs ne changent pas, le computed n'est pas recalculé. C'est l'équivalent direct des selectors NgRx, mais sans la cérémonie de createSelector.
Hooks de cycle de vie. withHooks({ onInit, onDestroy }) donne accès au cycle de vie du store. onInit est parfait pour déclencher un chargement initial, configurer un polling, ou s'abonner à un router event. onDestroy permet de nettoyer (mais rxMethod gère déjà le teardown des observables).
Type inference automatique. Une force majeure : les types sont inférés, on ne déclare jamais explicitement le type final du store. Si on ajoute withState({ foo: 1 }), le store gagne foo: Signal<number> et patchState accepte { foo: number }. Cela élimine les erreurs de désynchronisation entre types et runtime.
Stores comme services orientés domaine. Un SignalStore ne remplace pas seulement NgRx, il remplace aussi les services-stateful avec BehaviorSubject. Un CartService qui maintient un panier devient CartStore avec des méthodes claires, un state typé, et des computed signaux pour le total.
Feature withDevtools pour le debug. En 2025+, un package communautaire @angular-architects/ngrx-toolkit propose withDevtools() à composer dans le store pour s'intégrer aux Redux DevTools. C'est un compromis acceptable en attendant les DevTools officielles SignalStore.
import { withDevtools } from '@angular-architects/ngrx-toolkit';
export const TodosStore = signalStore(
{ providedIn: 'root' },
withDevtools('todos'),
withState(initialState),
// ...
);Feature withStorageSync pour la persistance. Pattern réutilisable pour synchroniser une partie du state avec localStorage. On crée une feature qui ajoute un effect interne au store.
// features/with-storage-sync.ts
import { effect, EffectRef } from '@angular/core';
import { signalStoreFeature, withHooks, getState, patchState } from '@ngrx/signals';
export function withStorageSync<T>(key: string) {
return signalStoreFeature(
withHooks({
onInit(store) {
const raw = localStorage.getItem(key);
if (raw) {
try { patchState(store, JSON.parse(raw)); } catch { /* ignore */ }
}
effect(() => {
const state = getState(store);
localStorage.setItem(key, JSON.stringify(state));
});
},
}),
);
}🏛️ Comment un staff engineer raisonne sur SignalStore
Le débat « SignalStore vs NgRx classic » est un faux débat. La vraie question est : quelle est la frontière de cohérence de mon état, et qui a le droit de le muter ? SignalStore ne change pas cette analyse — il change seulement le coût d'implémentation. Voici la grille de lecture senior.
1. Le store est un agrégat (DDD), pas un sac de variables. Un bon SignalStore correspond à une frontière transactionnelle : tout ce qui doit rester mutuellement cohérent vit dans le même store. Un CartStore détient les lignes, le total, le code promo — parce qu'ajouter une ligne et recalculer le total doivent être atomiques. À l'inverse, ne mettez pas theme, cart et currentUser dans un méga-store : ce sont trois frontières de cohérence indépendantes, donc trois stores. Un patchState doit toujours laisser l'agrégat dans un état valide.
2. Encapsulation : exposer des intentions, cacher les setters. Le piège débutant est de traiter le store comme un bag mutable public. Un store de niveau senior expose des méthodes-intention (checkout(), applyPromo(code)) et garde le state en lecture seule. Depuis @ngrx/signals ≥ 19, l'encapsulation n'est plus une simple convention : tout slice de state, computed ou méthode préfixé par _ est réellement exclu du type public du store. store._lines n'existe pas dans le type consommé par le composant — c'est une vraie barrière TypeScript, pas du folklore. Combiné à des computed publics en lecture seule, on obtient un agrégat dont l'état interne est inaccessible de l'extérieur :
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { computed } from '@angular/core';
export const CartStore = signalStore(
{ providedIn: 'root' },
// état brut — considéré « privé » par convention de préfixe
withState({ _lines: [] as CartLine[], _promo: null as string | null }),
withComputed(({ _lines, _promo }) => ({
// API publique : uniquement des dérivés en lecture seule
lines: computed(() => _lines()),
subtotal: computed(() => _lines().reduce((s, l) => s + l.price * l.qty, 0)),
discount: computed(() => (_promo() ? 0.1 : 0)),
total: computed(() => {
const sub = _lines().reduce((s, l) => s + l.price * l.qty, 0);
return sub * (1 - (_promo() ? 0.1 : 0));
}),
})),
withMethods((store) => ({
addLine(line: CartLine) {
patchState(store, (s) => ({ _lines: [...s._lines, line] }));
},
applyPromo(code: string) {
patchState(store, { _promo: code });
},
})),
);Le composant ne voit que store.lines(), store.total(), store.addLine() — le préfixe _ retire _lines/_promo du type public, donc store._lines ne compile pas dans un composant. Reste une dernière fuite : patchState(store, ...) est appelable de l'extérieur si on importe patchState et la référence du store (la fonction est globale, pas une méthode). C'est une friction volontaire — pas une protection runtime — mais en pratique un reviewer attrape un patchState hors du fichier store immédiatement. Cette discipline est ce qui distingue un store maintenable d'un BehaviorSubject public déguisé.
Nuance staff : l'encapsulation
_protège la lecture du type, pas la mutation runtime. Si vous voulez une garantie dure (lib partagée, équipe large), enveloppez le store dans un service façade qui n'expose qu'une interface explicite, ou exposez desDeepReadonlyviagetState. Le_couvre 95 % des cas internes ; la façade couvre les frontières de contrat.
3. patchState est une transaction, pas un setter. Chaque patchState est appliqué de façon synchrone et atomique : tous les signals affectés sont mis à jour avant qu'un computed ou effect ne s'exécute (glitch-free propagation des signals Angular). Conséquence pratique : ne jamais faire deux patchState consécutifs là où un seul suffit — chaque appel est une transaction distincte qui peut déclencher des effects intermédiaires sur un état partiellement mis à jour.
// ❌ deux transactions : un effect peut s'exécuter entre les deux,
// voyant loading=false mais data encore vide
patchState(store, { data });
patchState(store, { loading: false });
// ✅ une transaction atomique
patchState(store, { data, loading: false });4. Frontière signals/RxJS. Règle mentale : le state est en signals, les événements restent en RxJS. Un clic isolé → méthode synchrone + patchState. Un flux d'événements qui demande du flow-control (debounce, switchMap, retry, race) → rxMethod. Ne convertissez pas un Observable en signal juste pour le re-convertir : rxMethod accepte directement valeur, Observable ou Signal en entrée, et gère le teardown via le DestroyRef du contexte d'injection.
5. Tableau de décision : où mettre la logique ?
| Besoin | Slot | Pourquoi |
|---|---|---|
| Valeur dérivée pure, mémoïsée | withComputed | recalcul lazy, glitch-free, équivalent selector |
| Mutation synchrone d'intention | withMethods + patchState | atomique, testable sans async |
| Flux async avec flow-control | rxMethod | cancellation, retry, debounce, teardown auto |
| Réaction à un changement de state (log, sync, analytics) | effect dans withHooks.onInit | side-effect, pas de valeur de retour |
| Logique transversale réutilisable | signalStoreFeature | composition typée, tree-shakable |
| Init / polling / restore | withHooks.onInit | accès au store construit, contexte d'injection sortant |
🔬 Deep types — pourquoi signalStoreFeature est l'arme nucléaire
La vraie raison pour laquelle SignalStore bat NgRx classic à l'échelle, ce n'est pas le boilerplate — c'est que les features sont des transformations de type composables. Chaque feature reçoit en générique l'état accumulé (StateSignals, Methods, Props) des features précédentes et déclare ce qu'elle requiert et ce qu'elle ajoute. C'est de l'algèbre de types.
Une feature qui exige qu'un slice existe en entrée :
// features/with-selection.ts
import { computed } from '@angular/core';
import {
signalStoreFeature, type, withState, withComputed, withMethods, patchState,
} from '@ngrx/signals';
// Cette feature REQUIERT que le store expose déjà `items: Signal<{ id: string }[]>`.
// `type<...>()` est le moyen de déclarer une contrainte sur l'état entrant
// sans rien ajouter au runtime — pure information de type.
export function withSelection<T extends { id: string }>() {
return signalStoreFeature(
{ state: type<{ items: T[] }>() }, // contrainte d'entrée
withState({ selectedId: null as string | null }),
withComputed(({ items, selectedId }) => ({
selected: computed(() => items().find((i) => i.id === selectedId()) ?? null),
hasSelection: computed(() => selectedId() !== null),
})),
withMethods((store) => ({
select(id: string) { patchState(store, { selectedId: id }); },
clearSelection() { patchState(store, { selectedId: null }); },
})),
);
}Si on compose withSelection() sur un store qui n'a pas de items, la compilation échoue — pas un crash runtime, une erreur TS à l'endroit exact. C'est ce qui rend les features réutilisables sûres : withSelection, withPagination, withDirty, withUndoRedo deviennent une bibliothèque interne qu'on empile, et le compilateur garantit la compatibilité. NgRx classic n'a aucun équivalent : un selector mal câblé ne se voit qu'au runtime.
Convention d'ordre. Les features s'appliquent de gauche à droite. Une feature ne peut référencer que ce que les features à sa gauche ont déclaré. Donc : withEntities / withState d'abord, puis les withComputed/withMethods qui en dépendent, puis les features transversales (withSelection, withDevtools) en dernier.
withProps — injecter des dépendances et des constantes non réactives. À côté de withState (slots réactifs) existe withProps : il ajoute au store des propriétés non-signal, idéales pour exposer une dépendance injectée une seule fois (un client API, un Router, un WritableSignal partagé) plutôt que de la inject(...) dans chaque withMethods. C'est le slot canonique pour le DI dans un store composable.
import { signalStoreFeature, withProps, withMethods } from '@ngrx/signals';
import { inject } from '@angular/core';
import { TodosApi } from './todos.api';
export function withTodosApi() {
return signalStoreFeature(
withProps(() => ({ _api: inject(TodosApi) })), // résolu dans le contexte d'injection du store
withMethods((store) => ({
// store._api est typé, partagé, résolu une fois — pas de inject() répété
reload() { store._api.list(); },
})),
);
}withProps s'exécute pendant la construction du store (contexte d'injection actif), donc inject() y est légal — contrairement à withHooks.onInit où le contexte est déjà sorti. Préférez withProps à inject() répété dès qu'une dépendance est utilisée par plusieurs méthodes ou plusieurs features.
📈 Performance & scale — le store à 10k+ entités
Le SignalStore est rapide par défaut parce que les signals propagent en push et que computed mémoïse. Mais à l'échelle, quatre pièges reviennent :
1. computed qui retourne une nouvelle référence à chaque lecture. computed(() => items().filter(...)) recrée un tableau à chaque recalcul. Si ce tableau alimente un @for sans track correct, ou un autre computed en aval qui fait ===, on perd la mémoïsation. Toujours track item.id dans le template, et envisager un computed intermédiaire stable.
2. Préférer withEntities pour les grosses collections. Il stocke en Record<id, entity> + ids: string[]. Un updateEntity({ id, changes }) ne touche qu'une entrée — les composants qui lisent une autre entité ne sont pas invalidés si on combine avec une lecture ciblée. Un Todo[] brut force la recréation du tableau entier à chaque mutation.
3. linkedSignal (Angular 19+) pour un état dérivé mais éditable. Cas typique : une sélection qui doit se réinitialiser quand la liste source change, mais que l'utilisateur peut aussi modifier. Avant, on bricolait avec effect. Désormais :
import { linkedSignal } from '@angular/core';
// se recalcule depuis source(), mais reste assignable par l'utilisateur
const selectedId = linkedSignal(() => items()[0]?.id ?? null);4. untracked pour découpler les lectures non réactives. Dans un effect qui sync vers localStorage ou analytics, lire version() dans untracked(() => ...) évite de re-déclencher l'effect sur ce signal précis. Sans ça, on crée des boucles d'effects coûteuses.
Observabilité. En prod, on veut savoir pourquoi un store re-rend. Patron senior : une feature withTelemetry() qui pose un effect loggant les transitions de state (échantillonné), couplée à withDevtools en dev uniquement (tree-shaké en prod via un garde isDevMode()). Ne jamais laisser withDevtools dans le bundle de prod : il garde une référence à chaque snapshot d'état = fuite mémoire.
🤖 Serveur d'agents IA : streaming de tokens dans une UI SignalStore
Le stack de ce repo sert et consomme des agents IA. Le SignalStore est l'endroit idéal pour piloter une conversation LLM en streaming : append-only message buffer, trace d'outils en discriminated-union, bouton Stop câblé à un AbortController. Voici le patron de référence, zoneless-compatible, qui consomme un endpoint SSE NestJS streamant des tokens Claude (claude-opus-4-8, claude-sonnet-4-6 ou claude-haiku-4-5 selon le coût/latence visé).
// chat.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { ChatApi } from './chat.api';
// Trace d'outils en union discriminée — chaque étape agentique est typée
type ToolStep =
| { kind: 'tool'; id: string; name: string; status: 'pending' | 'running' | 'done' | 'error'; output?: string };
interface Msg {
readonly id: string;
readonly role: 'user' | 'assistant';
readonly content: string; // buffer append-only
readonly steps: ToolStep[]; // timeline d'outils
readonly streaming: boolean;
}
interface ChatState {
messages: Msg[];
sending: boolean;
abort: AbortController | null;
}
export const ChatStore = signalStore(
{ providedIn: 'root' },
withState<ChatState>({ messages: [], sending: false, abort: null }),
withComputed(({ messages, sending }) => ({
lastAssistant: computed(() => [...messages()].reverse().find((m) => m.role === 'assistant') ?? null),
canSend: computed(() => !sending()),
})),
withMethods((store, api = inject(ChatApi)) => ({
stop() {
store.abort()?.abort(); // annule côté client…
api.cancel(store.messages().at(-1)?.id); // …ET côté serveur (l'agentic loop NestJS écoute le disconnect)
patchState(store, { sending: false, abort: null });
},
async send(prompt: string) {
if (store.sending()) return; // garde anti-double-envoi
const userMsg: Msg = { id: crypto.randomUUID(), role: 'user', content: prompt, steps: [], streaming: false };
const aiId = crypto.randomUUID();
const aiMsg: Msg = { id: aiId, role: 'assistant', content: '', steps: [], streaming: true };
const abort = new AbortController();
patchState(store, (s) => ({ messages: [...s.messages, userMsg, aiMsg], sending: true, abort }));
// rAF-coalescing : on accumule les deltas et on ne patch qu'une fois par frame
let buffer = '';
let scheduled = false;
const flush = () => {
scheduled = false;
patchState(store, (s) => ({
messages: s.messages.map((m) => (m.id === aiId ? { ...m, content: m.content + buffer } : m)),
}));
buffer = '';
};
try {
// getReader() + TextDecoder sur un fetch ReadableStream (SSE serveur Claude)
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: abort.signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (const evt of parseSSE(decoder.decode(value, { stream: true }))) {
if (evt.type === 'token') {
buffer += evt.text;
if (!scheduled) { scheduled = true; requestAnimationFrame(flush); }
} else if (evt.type === 'tool') {
patchState(store, (s) => ({
messages: s.messages.map((m) =>
m.id === aiId ? { ...m, steps: upsertStep(m.steps, evt.step) } : m),
}));
}
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
patchState(store, (s) => ({
messages: s.messages.map((m) => (m.id === aiId ? { ...m, content: m.content + '\n[erreur]' } : m)),
}));
}
} finally {
if (buffer) flush();
patchState(store, (s) => ({
messages: s.messages.map((m) => (m.id === aiId ? { ...m, streaming: false } : m)),
sending: false, abort: null,
}));
}
},
})),
);Points senior dans ce patron :
- rAF-coalescing : en zoneless, chaque
patchStatere-rend le composant. Streamer 200 tokens = 200 re-renders. On bufferise les deltas et on flush une fois par frame d'animation — fluide même à haut débit. - Append-only immuable : on ne mute jamais un
Msg, on remappe le tableau. C'est ce qui rend l'historique trivial à sérialiser, rejouer, ou tester. - Stop des deux côtés :
AbortControllerannule la requête réseau locale, et on notifie le serveur pour qu'il coupe l'agentic loop et arrête de payer des tokens. Annuler seulement côté client laisse Claude générer (et facturer) dans le vide. - Trace d'outils en union discriminée : chaque tool-call passe
pending → running → done|error, rendu en timeline. C'est l'observabilité côté UX de la boucle agentique serveur. - Markdown : pour rendre
content, parser en markdown puis passer parDomSanitizer.sanitize(SecurityContext.HTML, ...)— jamaisinnerHTMLbrut sur de la sortie LLM (injection prompt → XSS).
Côté NestJS (rappel d'intégration, hors scope détaillé ici) : le client Anthropic est injecté via forRootAsync (pas de new Anthropic() dans un champ), le stream est exposé en SSE, l'AbortController du client se mappe sur le req.on('close') pour couper la boucle, et chaque génération porte un generationId idempotent pour les retries BullMQ cost-aware. Les helpers parseSSE(chunk) (découpe les frames data: …) et upsertStep(steps, step) (insère/maj une étape par id) sont des utilitaires purs, triviaux à tester en isolation.
🔄 Versions — Angular 16 → 20
Angular 16 / NgRx 16 (mi-2023) : @ngrx/signals apparaît en preview. API expérimentale, breaking changes attendus. signalState et patchState sont disponibles.
Angular 17 / NgRx 17 (fin 2023) : signalStore devient stable. withState, withComputed, withMethods, withHooks sont les quatre features de base. rxMethod est introduit pour les flux async.
Angular 18 / NgRx 18 (mi-2024) : stabilisation des custom features via signalStoreFeature. Pattern de composition mature. Intégration améliorée avec Angular Material et CDK. Documentation officielle complète.
Angular 19 / NgRx 19 (fin 2024) : zoneless ready. SignalStore devient le défaut recommandé par la doc officielle pour les nouveaux projets. Apparition du package @ngrx/signals/entities (équivalent SignalStore de @ngrx/entity).
Angular 20 / NgRx 20 (mi-2025) : withEntities officiellement stable — équivalent direct de createEntityAdapter pour les collections. DevTools dédiées SignalStore (extension Chrome séparée, plus simple que NgRx DevTools). Performance optimisée pour grands stores (10k+ entités).
Trajectoire 2026 : SignalStore est le défaut pour les apps Angular modernes. NgRx classic reste pour les contextes très Redux-oriented. ComponentStore est en maintenance, on migre progressivement vers SignalStore. L'écosystème (Apollo, etc.) propose des adapters natifs SignalStore.
⚠️ Pitfalls — 6-10
1. Muter directement le state. store.todos().push(newTodo) ne déclenche aucune réactivité car la référence du tableau ne change pas, et viole l'immutabilité attendue. Toujours passer par patchState(store, (s) => ({ todos: [...s.todos, newTodo] })). TypeScript ne protège pas tout : la signature accepte Todo[], c'est à l'auteur d'éviter les mutations.
2. Oublier que rxMethod est un singleton dans le store. Si on appelle store.loadAll() rapidement plusieurs fois, c'est le switchMap (ou autre opérateur) à l'intérieur du rxMethod qui gère la cancellation. Mal configuré (mergeMap par défaut), on peut avoir plusieurs requêtes parallèles non annulées.
3. Computed lourds non mémoïsés correctement. Un computed qui dépend d'un signal qui change souvent (typage utilisateur) peut être recalculé à chaque keystroke. Vérifier que les inputs du computed sont stables, ou ajouter un linkedSignal / untracked pour isoler les recalculs coûteux.
4. Store providedIn root pour un state route-scoped. Un store global pour un wizard temporaire pollue la mémoire et conserve l'état entre navigations. Pour un state lié à une route, fournir le store dans route.providers ou dans le composant lui-même.
5. Effets dans withHooks.onInit sans contexte d'injection. inject() fonctionne dans withMethods car il s'exécute pendant la construction. Mais dans onInit, l'injection context est déjà sorti — on doit utiliser store directement, pas faire de nouveaux inject(...).
6. Confusion entre signalStoreFeature et fonctions utilitaires. signalStoreFeature doit retourner les features composées via la fonction homonyme. Un simple objet ne fonctionne pas. Et le type générique des features dépend de l'état attendu — bien typer les paramètres pour réutilisation.
7. Manque de DevTools natives. Contrairement à NgRx classic, le SignalStore n'a pas (encore en 2026) de DevTools time-travel complètes. On dispose d'un inspecteur d'état mais pas du replay d'actions. C'est un trade-off à mesurer pour les apps qui dépendent de cet outil de support.
8. Tests qui consomment le store via injection sans TestBed. Le store standalone ({ providedIn: 'root' }) doit être instancié dans un TestBed. Sinon, inject() dans withMethods échoue. Utiliser TestBed.configureTestingModule({}) puis TestBed.inject(MyStore).
9. Type des updates de patchState mal inférés. Quand on passe un objet trop large à patchState, TypeScript peut accepter des clés en trop sans erreur (à cause du widening). Préférer la forme patchState(store, (s) => ({ field: newValue })) qui force l'inférence stricte.
10. Oublier la sémantique de rxMethod avec input signal. Si on passe un signal à rxMethod, le rxMethod réagit à chaque changement du signal. C'est puissant mais peut surprendre : un toggle sur un filtre déclenche immédiatement un re-fetch. Vérifier l'opérateur (debounce, distinctUntilChanged) pour éviter les appels excessifs.
11. Confondre withState initial avec un type strict. Le type d'état est inféré depuis l'objet passé à withState. Si un champ est null, le type devient null strict, pas T | null. Toujours typer explicitement : withState<MyState>({ ... }) ou utiliser des valeurs initiales qui couvrent le type complet.
12. Effets concurrents non gérés. Sans switchMap (ou concatMap, exhaustMap), un rxMethod qui déclenche un appel HTTP à chaque clic peut accumuler des requêtes en parallèle. switchMap annule la précédente — comportement attendu pour des recherches, mauvais pour des saves (préférer exhaustMap ou concatMap).
🧪 Testing
Les tests SignalStore sont beaucoup plus simples que pour NgRx classic : pas d'actions à dispatch, pas d'effects à mocker, juste un service avec un état signal.
// todos.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { TodosStore } from './todos.store';
import { TodosApi } from './todos.api';
import { of } from 'rxjs';
describe('TodosStore', () => {
let api: jasmine.SpyObj<TodosApi>;
beforeEach(() => {
api = jasmine.createSpyObj('TodosApi', ['list']);
api.list.and.returnValue(of([]));
TestBed.configureTestingModule({
providers: [{ provide: TodosApi, useValue: api }],
});
});
it('ajoute une tâche', () => {
const store = TestBed.inject(TodosStore);
store.add('Apprendre SignalStore');
expect(store.todos().length).toBe(1);
expect(store.todos()[0].title).toBe('Apprendre SignalStore');
});
it('filtre les tâches actives', () => {
const store = TestBed.inject(TodosStore);
store.add('A');
store.add('B');
store.toggle(store.todos()[0].id);
store.setFilter('active');
expect(store.filteredTodos().length).toBe(1);
});
it('calcule activeCount correctement', () => {
const store = TestBed.inject(TodosStore);
store.add('A');
store.add('B');
expect(store.activeCount()).toBe(2);
store.toggle(store.todos()[0].id);
expect(store.activeCount()).toBe(1);
});
});Pour tester un rxMethod async, on utilise fakeAsync ou observer-spy.
import { fakeAsync, tick } from '@angular/core/testing';
it('charge les todos en async', fakeAsync(() => {
api.list.and.returnValue(of([{ id: '1', title: 'X', done: false, priority: 'low' as const }]));
const store = TestBed.inject(TodosStore);
store.loadAll();
tick();
expect(store.todos().length).toBe(1);
expect(store.loading()).toBe(false);
}));Pour tester un computed isolé, on peut utiliser TestBed.runInInjectionContext.
import { TestBed } from '@angular/core/testing';
it('filteredTodos réagit au filtre', () => {
TestBed.runInInjectionContext(() => {
const store = TestBed.inject(TodosStore);
store.add('Tâche');
expect(store.filteredTodos().length).toBe(1);
store.setFilter('done');
expect(store.filteredTodos().length).toBe(0);
});
});Test d'un signalStoreFeature isolé.
// features/with-pagination.spec.ts
import { signalStore, withState, getState } from '@ngrx/signals';
import { TestBed } from '@angular/core/testing';
import { withPagination } from './with-pagination';
describe('withPagination', () => {
const TestStore = signalStore(
{ providedIn: 'root' },
withState({ items: [] }),
withPagination(10),
);
it('démarre à la page 1', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(TestStore);
expect(store.page()).toBe(1);
expect(store.offset()).toBe(0);
});
it('navigue à la page suivante', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(TestStore);
store.nextPage();
expect(store.page()).toBe(2);
expect(store.offset()).toBe(10);
});
it('ne descend pas en dessous de la page 1', () => {
TestBed.configureTestingModule({});
const store = TestBed.inject(TestStore);
store.prevPage();
expect(store.page()).toBe(1);
});
});Tester le ChatStore streamé (le piège du fetch + rAF). Le store IA est asynchrone, dépend de fetch global et de requestAnimationFrame. On mocke fetch pour qu'il renvoie un ReadableStream scripté, et on remplace requestAnimationFrame par un flush synchrone afin de rendre le test déterministe.
// chat.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { ChatStore } from './chat.store';
import { ChatApi } from './chat.api';
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
return new ReadableStream({
start(c) { for (const ch of chunks) c.enqueue(enc.encode(ch)); c.close(); },
});
}
describe('ChatStore (streaming)', () => {
beforeEach(() => {
// rAF synchrone => pas de timing flaky
spyOn(window, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { cb(0); return 0; });
TestBed.configureTestingModule({
providers: [{ provide: ChatApi, useValue: { cancel: jasmine.createSpy('cancel') } }],
});
});
it('accumule les tokens dans le message assistant', async () => {
spyOn(window, 'fetch').and.resolveTo(
new Response(streamOf(['data: {"type":"token","text":"Hel"}\n\n', 'data: {"type":"token","text":"lo"}\n\n'])),
);
const store = TestBed.inject(ChatStore);
await store.send('salut');
expect(store.lastAssistant()?.content).toBe('Hello');
expect(store.lastAssistant()?.streaming).toBeFalse();
expect(store.sending()).toBeFalse();
});
it('marque le message en erreur sur échec réseau (hors AbortError)', async () => {
spyOn(window, 'fetch').and.rejectWith(new TypeError('network down'));
const store = TestBed.inject(ChatStore);
await store.send('salut');
expect(store.lastAssistant()?.content).toContain('[erreur]');
expect(store.sending()).toBeFalse(); // le finally remet toujours sending à false
});
});Leçon de test senior : un store qui parle au réseau doit garder ses invariants de teardown (sending=false, abort=null, streaming=false) dans un finally, précisément pour qu'ils soient testables sans dérouler tout le happy path. Si vous ne pouvez pas écrire le test « échec réseau » en 5 lignes, votre gestion d'erreur est probablement éparpillée hors du finally.
Test d'un store avec withStorageSync.
describe('TodosStore avec persistance', () => {
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({});
});
it('restaure le state au démarrage', () => {
localStorage.setItem('todos', JSON.stringify({ todos: [{ id: '1', title: 'Restored', done: false, priority: 'low' }] }));
const store = TestBed.inject(TodosStore);
expect(store.todos().length).toBe(1);
expect(store.todos()[0].title).toBe('Restored');
});
});🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique, store dossier client
Contexte : application interne d'un cabinet d'avocats d'affaires (50 collaborateurs) où chaque dossier client agrège pièces, notes, échéances, facturation, jurisprudences liées. L'avocat ouvre un dossier et travaille dessus pendant des heures : on a besoin d'un state riche, fortement typé, scopé au composant route (/dossiers/:id) qui se détruit proprement quand on quitte. SignalStore est idéal en mode providedIn: 'self' sur le composant route : withState({ dossier, pieces, notes, echeances }), withComputed({ piecesEnAttente, prochaineEcheance, montantFacture }), withMethods({ ajouterNote, marquerPieceLue, planifierEcheance }), withHooks({ onInit: load(id), onDestroy: saveDraft() }). Le binding template devient direct : store.dossier(), store.prochaineEcheance(), sans async pipe. La fonctionnalité annoter une pièce ouvre un drawer qui partage le même store via inject(DossierStore, { skipSelf: true }). Quand l'avocat sauvegarde, rxMethod enchaîne validation → HTTP PATCH → toast → rechargement partiel. Zéro boilerplate par rapport à NgRx classic pour un comportement aussi riche.
Scénario 2 — E-commerce mode, mini-store catalogue page
Contexte : page catégorie d'un retailer mode (1500 produits par catégorie) avec filtres facettés (taille, couleur, prix, marque), tri, pagination, vue grille/liste, et préview rapide. On veut un store par instance de page catégorie, donc fourni au niveau composant, qui se reset quand on change de catégorie. signalStore({ providedIn: null }) + withState({ filters, sort, view, productsAll, page }) + withComputed({ productsFiltered: () => apply(productsAll(), filters()), productsPage: () => paginate(productsFiltered(), page()) }). Les facettes elles-mêmes sont des computed dérivés : availableSizes = () => unique(productsAll().flatMap(p => p.sizes)). Quand l'utilisateur clique un filtre, patchState(this, { filters: {...filters(), color: 'red'} }) — instantané, sans aller-retour réseau (les produits sont chargés une fois par catégorie). Pour la persistance des filtres dans l'URL, un withHooks synchronise filters() avec Router.navigate via effect(). Le résultat : interactions facettes en sub-frame, sans NgRx, sans RxJS visible, en 150 lignes.
Scénario 3 — SaaS RH, paramétrage entreprise
Contexte : module paramétrage du SaaS RH où l'admin configure les paramètres entreprise (logo, couleurs, workflows ATS, modèles emails, intégrations SSO). Données structurées en arbre, modifications batchées, prévisualisation live, save par section. SignalStore composé de plusieurs features locales : withCompanyBranding(), withWorkflows(), withEmailTemplates(), withIntegrations() — chacune apporte son slice de state, ses computed, ses méthodes. La feature withDirty() réutilisable trace les sections modifiées (dirtySections: Signal<Set<string>>) et active le bouton "Enregistrer". withAutoSave() configure un effect qui déclenche save() 3s après la dernière modification via un debounce signal. Cette composition de features est le vrai super-pouvoir de SignalStore vs NgRx classic — réutiliser withDirty() sur trois stores différents (paramétrage, fiche candidat, fiche poste) sans copier-coller.
🛠️ Exemple end-to-end
Use case : store dossier juridique avec pièces, notes, échéances, scopé à la route détail.
// dossier.model.ts
export type PieceStatut = 'recu' | 'lu' | 'commente';
export interface Piece {
readonly id: string;
readonly nom: string;
readonly statut: PieceStatut;
readonly dateAjout: string;
}
export interface Note { readonly id: string; readonly texte: string; readonly auteur: string; readonly at: string; }
export interface Echeance { readonly id: string; readonly libelle: string; readonly date: string; }
export interface Dossier {
readonly id: string;
readonly client: string;
readonly intitule: string;
readonly pieces: ReadonlyArray<Piece>;
readonly notes: ReadonlyArray<Note>;
readonly echeances: ReadonlyArray<Echeance>;
}// dossier.store.ts
import { computed, inject } from '@angular/core';
import { signalStore, withState, withComputed, withMethods, withHooks, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, debounceTime } from 'rxjs';
import { DossierApi } from './dossier.api';
import { Dossier, Piece, Note } from './dossier.model';
interface DossierState {
readonly dossier: Dossier | null;
readonly loading: boolean;
readonly error: string | null;
readonly dirty: boolean;
}
const initial: DossierState = { dossier: null, loading: false, error: null, dirty: false };
export const DossierStore = signalStore(
withState(initial),
withComputed(({ dossier }) => ({
piecesEnAttente: computed(() => dossier()?.pieces.filter((p) => p.statut === 'recu').length ?? 0),
prochaineEcheance: computed(() => {
const eds = dossier()?.echeances ?? [];
return [...eds].sort((a, b) => a.date.localeCompare(b.date))[0] ?? null;
}),
totalNotes: computed(() => dossier()?.notes.length ?? 0),
})),
withMethods((store, api = inject(DossierApi)) => ({
loadDossier: rxMethod<string>(
pipe(
tap(() => patchState(store, { loading: true, error: null })),
switchMap((id) => api.get(id)),
tap({
next: (dossier) => patchState(store, { dossier, loading: false, dirty: false }),
error: (err: Error) => patchState(store, { loading: false, error: err.message }),
}),
),
),
marquerPieceLue(pieceId: string) {
const d = store.dossier();
if (!d) return;
const pieces = d.pieces.map((p) => p.id === pieceId ? { ...p, statut: 'lu' as const } : p);
patchState(store, { dossier: { ...d, pieces }, dirty: true });
},
ajouterNote(texte: string, auteur: string) {
const d = store.dossier();
if (!d) return;
const note: Note = { id: crypto.randomUUID(), texte, auteur, at: new Date().toISOString() };
patchState(store, { dossier: { ...d, notes: [...d.notes, note] }, dirty: true });
},
sauvegarder: rxMethod<void>(
pipe(
debounceTime(300),
switchMap(() => {
const d = store.dossier();
return d ? api.save(d) : [];
}),
tap(() => patchState(store, { dirty: false })),
),
),
})),
withHooks({
onInit(store) {
// L'id viendra du composant route via loadDossier(id)
},
}),
);// dossier-detail.component.ts
import { ChangeDetectionStrategy, Component, effect, inject, input } from '@angular/core';
import { DossierStore } from './dossier.store';
@Component({
selector: 'app-dossier-detail',
providers: [DossierStore],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (store.loading()) { <p>Chargement…</p> }
@if (store.dossier(); as d) {
<header>
<h1>{{ d.client }} — {{ d.intitule }}</h1>
<span>{{ store.piecesEnAttente() }} pièce(s) en attente</span>
@if (store.prochaineEcheance(); as e) {
<span>Prochaine échéance : {{ e.libelle }} ({{ e.date }})</span>
}
</header>
<section>
<h2>Pièces</h2>
@for (p of d.pieces; track p.id) {
<div>
<span>{{ p.nom }} — {{ p.statut }}</span>
@if (p.statut === 'recu') {
<button (click)="store.marquerPieceLue(p.id)">Marquer lue</button>
}
</div>
}
</section>
<section>
<h2>Notes ({{ store.totalNotes() }})</h2>
<button (click)="store.ajouterNote('Note rapide', 'Me Dupont')">Ajouter</button>
</section>
@if (store.dirty()) {
<footer><button (click)="store.sauvegarder()">Enregistrer</button></footer>
}
}
`,
})
export class DossierDetailComponent {
readonly id = input.required<string>();
protected readonly store = inject(DossierStore);
constructor() {
effect(() => { this.store.loadDossier(this.id()); });
}
}Tout le cycle (chargement, mutations optimistes, dérivés computed, save debounced) en ~120 lignes. Le store est détruit avec le composant, pas de fuite mémoire, pas d'actions à écrire.
🔁 Quand utiliser / éviter
| Utiliser SignalStore quand | Éviter SignalStore quand |
|---|---|
| Nouveau projet Angular 17+ avec besoin de state structuré | App existante avec NgRx classic profondément intégré |
| State feature-scoped ou domaine (panier, wizard, dashboard) | Time-travel debugging et replay sont critiques (support, audit) |
| Équipe à l'aise avec les signals et la composition | Logique très Redux-oriented avec middleware custom (logging, persistence) |
| Migration progressive d'un service avec BehaviorSubject | State partagé entre plusieurs apps avec un contrat strict d'actions |
| Besoin de zoneless ou apps performance-critical | Très grosses équipes habituées au pattern actions/reducers |
| State mêlant sync (signals) et async (RxJS) via rxMethod | Besoin d'outils tiers Redux (Redux Saga port, etc.) |
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Chaque exercice suppose Angular 19/20 + @ngrx/signals 19/20.
Exercice 1 — withUndoRedo() générique (implémenter)
Objectif : écrire une signalStoreFeature réutilisable qui ajoute undo(), redo(), canUndo, canRedo à n'importe quel store, sans coupler la feature à une forme de state précise.
Indice/Solution : maintenir deux piles (past, future) de snapshots via getState(store). Sur chaque mutation « trackée », push l'état précédent dans past, vide future. undo() pop past → patchState(store, snapshot) → push l'état courant dans future. Exposer canUndo = computed(() => past().length > 0). Piège : ne pas tracker ses propres restaurations (sinon boucle). Utiliser un flag _restoring privé.
Exercice 2 — withEntities + recherche debouncée serveur (production-grade)
Objectif : store catalogue avec withEntities<Product>(), une recherche serveur déclenchée par un signal query, debouncée 300ms, qui annule la requête précédente et gère loading/error par requête.
Indice/Solution : rxMethod<string>(pipe(debounceTime(300), distinctUntilChanged(), switchMap(q => api.search(q).pipe(...)))). Brancher l'entrée sur un signal : searchProducts(this.query) — le rxMethod ré-exécute à chaque changement du signal. switchMap garantit l'annulation. Mémoïser les facettes en withComputed. Production-grade = état requestId pour ignorer les réponses obsolètes même si l'API ne se cancel pas côté réseau.
Exercice 3 — Streaming LLM avec rAF-coalescing (production-grade)
Objectif : reprendre le ChatStore de la section IA et le rendre robuste : reconnexion sur coupure réseau (reprendre le stream là où il s'est arrêté via un curseur d'offset), backpressure si le décodeur va plus vite que le rendu, et un timeout qui marque le message error après 30s sans token.
Indice/Solution : garder un lastTokenAt mis à jour à chaque delta ; un setTimeout rearmé qui abort + marque erreur. Pour la reprise, le serveur doit accepter un header Last-Event-ID (SSE natif) ; le store conserve l'offset du dernier token reçu. Le rAF-coalescing fournit déjà la backpressure côté rendu : on n'émet qu'1 frame max, peu importe le débit entrant.
Exercice 4 — Casser l'atomicité, puis réparer (break-then-fix)
Objectif : reproduire un bug de cohérence, le diagnostiquer, le corriger. Créer un store avec withState({ items, total }) et une méthode addItem qui fait deux patchState (un pour items, un pour total) avec un effect qui log total !== sum(items). Observer l'effect se déclencher sur l'état intermédiaire incohérent.
Indice/Solution : le bug vient des deux transactions séparées — l'effect voit items mis à jour mais total encore ancien. Fix : fusionner en un seul patchState(store, (s) => ({ items: [...], total: ... })). Bonus : mieux, ne pas stocker total du tout — c'est un computed. Stocker une valeur dérivée est l'anti-pattern racine ; le rendre dérivé élimine la classe de bug entière.
Exercice 5 — Fuite mémoire route-scopée (break-then-fix)
Objectif : prouver puis corriger une fuite. Déclarer un store de wizard en { providedIn: 'root' } avec un withHooks.onInit qui démarre un polling (interval(5000) dans un rxMethod). Naviguer en boucle entre le wizard et l'accueil. Observer que les pollings s'accumulent et que le state du wizard persiste entre visites.
Indice/Solution : un store root est singleton — onDestroy n'est jamais appelé, le polling ne s'arrête jamais. Fix : fournir le store dans les providers de la route/composant wizard. Sa durée de vie devient celle du composant ; rxMethod teardown automatiquement à la destruction. Vérifier dans les DevTools Angular que l'instance est bien recréée à chaque entrée.
Exercice 6 — withDirty() + garde de navigation (architecte)
Objectif : feature withDirty() qui trace les champs modifiés (Set<string>), expose isDirty, et s'intègre à un CanDeactivate guard pour bloquer la navigation avec données non sauvegardées — le tout sans que le composant connaisse les détails internes du store.
Indice/Solution : withDirty() ajoute _dirtyFields: Set<string> (privé) et un markDirty(field) appelé par les setters, plus isDirty = computed(() => _dirtyFields().size > 0) et markClean() après save. Le guard CanDeactivateFn fait inject(MyStore).isDirty() et retourne un confirm(). Architecte : la feature ne sait rien du domaine, le guard ne sait rien de l'implémentation — couplage minimal via l'interface signal.
🎤 En entretien
« Quand choisiriez-vous SignalStore plutôt que NgRx classic, et quand l'inverse ? » Par défaut SignalStore pour tout nouveau code : moins de boilerplate, zoneless-natif, composition typée. On garde NgRx classic uniquement si le time-travel debugging/replay d'actions est un besoin opérationnel (support, audit), ou si une grosse base existante repose déjà sur middleware Redux et un contrat d'actions partagé. Ce n'est pas une question de perf — c'est une question d'outillage et d'inertie.
« Pourquoi deux patchState consécutifs sont-ils un anti-pattern ? » Chaque patchState est une transaction synchrone atomique : tous les computed/effect réagissent après chaque appel. Deux appels exposent un état intermédiaire potentiellement incohérent à un effect, et doublent le travail de propagation. On fusionne en un seul updater. Et souvent, la deuxième valeur est en réalité un dérivé qui ne devrait pas être stocké du tout.
« Comment signalStoreFeature garantit-il la sûreté de composition que NgRx selectors n'a pas ? » Une feature est une transformation de type : elle déclare en générique l'état entrant requis (type<{ items: T[] }>()) et l'état sortant ajouté. Composer une feature sur un store incompatible est une erreur de compilation à l'endroit exact, pas un bug runtime. NgRx classic câble selectors et state au runtime ; une faute ne se voit qu'à l'exécution.
« Comment streamez-vous des tokens LLM dans une UI Angular sans tuer les perfs en zoneless, et comment annulez-vous proprement ? » Buffer append-only immuable des messages, deltas accumulés et flushés une fois par requestAnimationFrame (rAF-coalescing) pour éviter un re-render par token. Lecture via fetch().body.getReader() + TextDecoder. Annulation des deux côtés : AbortController coupe le réseau client, et on notifie le serveur pour stopper l'agentic loop et cesser de facturer des tokens. Sortie LLM rendue via markdown + DomSanitizer, jamais innerHTML brut.
« Quelle différence entre stocker un dérivé dans le state, un computed, et un linkedSignal ? » Trois niveaux. Un computed est dérivé pur, en lecture seule, mémoïsé : c'est le défaut pour toute valeur fonction de l'état (total, filtré, count). Stocker ce dérivé dans withState est l'anti-pattern racine — il peut désynchroniser de sa source. Le linkedSignal (Angular 19+) est le cas hybride : une valeur dérivée d'une source mais réassignable (une sélection qui se réinitialise quand la liste change, mais que l'utilisateur peut surcharger). Avant linkedSignal, on bricolait ça avec un effect qui re-patche — fragile et source de boucles. Règle : computed si jamais écrit par l'utilisateur ; linkedSignal si dérivé et éditable ; state brut seulement pour la source de vérité.
🔗 Liens
- Documentation officielle : https://ngrx.io/guide/signals
- Guide de migration depuis ComponentStore : https://ngrx.io/guide/signals/component-store-comparison
- Tim Deschryver — Introduction à SignalStore et patterns avancés
- Marko Stanimirović — auteur principal de @ngrx/signals (talks ng-conf 2023+)
- @ngrx/signals/entities : équivalent SignalStore de createEntityAdapter
- Comparatif officiel NgRx Store vs SignalStore : https://ngrx.io/guide/signals/store-comparison