NgRx 18+ — Store, Effects, Entity, Component Store
TL;DR NgRx applique Redux à Angular : un state global immuable, modifié uniquement via des actions (intentions sérialisables) traversant des reducers purs, lu via des selectors mémoïsés, et synchronisé avec le monde extérieur via des effects réactifs. En 2026, NgRx classic reste la référence pour les apps larges, multi-équipes, lourdes en logique métier asynchrone, mais cohabite avec
@ngrx/signals(SignalStore) qui devient le défaut pour les nouveaux projets de taille moyenne. Maîtriser NgRx classic, c'est comprendre quatre choses : la pureté des reducers, la mémoïsation des selectors, l'isolation des effects, et la structure d'actions. Tout le reste découle.
🧠 Mental model — ASCII + analogie
Imagine une mairie centralisée où chaque citoyen (composant) ne peut rien modifier directement dans les registres officiels (le state). Il doit déposer une demande écrite (une action) au guichet (le dispatcher). Le scribe (le reducer) recopie le registre entier en intégrant la modification — il ne raye jamais une ligne, il réécrit une nouvelle page. Pour consulter, on passe par des archivistes spécialisés (les selectors) qui mémorisent leurs réponses tant que la page concernée n'a pas changé. Et quand une demande nécessite d'appeler un fournisseur externe (HTTP, WebSocket), c'est le bureau des affaires extérieures (les effects) qui s'en occupe, puis dépose une nouvelle demande au guichet avec le résultat.
┌──────────────────────────────────────────────────────────────┐
│ NgRx Data Flow │
└──────────────────────────────────────────────────────────────┘
Component Component
│ ▲
│ dispatch(action) │ selector$
▼ │
┌──────────┐ action ┌──────────┐ new state ┌────────────┐
│ Store │─────────────▶│ Reducer │───────────────▶│ State │
│ (facade) │ │ (pure) │ │ (immutable)│
└──────────┘ └──────────┘ └────────────┘
│ │
│ action │ select()
▼ ▼
┌──────────┐ HTTP/WS ┌──────────┐ ┌────────────┐
│ Effects │◀──────────▶ │ World │ │ Selectors │
│ (RxJS) │ │ (APIs) │ │(memoized) │
└──────────┘ └──────────┘ └────────────┘
│ │
└─── dispatch(resultAction) ───────────────────────────▶│Le point critique à retenir : les reducers sont purs (pas d'HTTP, pas de Date.now(), pas de Math.random()), les selectors mémoïsent (un selector qui reçoit les mêmes inputs renvoie la même référence), et les effects sont les seuls à toucher au monde extérieur. Cette séparation stricte fait que le state est rejouable : on peut redonner la même séquence d'actions et obtenir exactement le même état. C'est ce qui rend les DevTools si puissantes (time-travel debugging).
🛠️ Code minimal (ts + html)
Configuration du store racine et d'un feature store.
// app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { provideRouterStore } from '@ngrx/router-store';
export const appConfig: ApplicationConfig = {
providers: [
// En dev, les runtime checks transforment une mutation accidentelle
// (state OU action) en ERREUR bruyante au lieu d'un bug silencieux.
// Coût non nul (deep-freeze récursif) → on les laisse OFF en prod.
provideStore({}, {
runtimeChecks: {
strictStateImmutability: isDevMode(),
strictActionImmutability: isDevMode(),
strictStateSerializability: isDevMode(), // pas de Date/Map/class dans le state
strictActionSerializability: isDevMode(), // pas de fn/Promise dans les actions
strictActionWithinNgZone: isDevMode(),
strictActionTypeUniqueness: isDevMode(), // détecte deux actions au même `type`
},
}),
provideEffects(),
provideRouterStore(),
provideStoreDevtools({
maxAge: 25,
logOnly: !isDevMode(),
autoPause: true,
trace: false,
traceLimit: 75,
connectInZone: true,
}),
],
};Pourquoi
serializabilitycompte. Mettre unDate, uneMap, une instance de classe ou une fonction dans le state (ou dans une action) casse silencieusement le time-travel des DevTools et la réhydratationlocalStorage(JSON.parserecrée un objet nu, pas votre classe). Stockez des primitives + objets plats (ISO string pour les dates,Recordpour les maps) et reconstruisez côté selector si besoin.
Un feature store complet pour une liste d'utilisateurs.
// users/user.model.ts
export interface User {
readonly id: string;
readonly name: string;
readonly email: string;
readonly role: 'admin' | 'member';
}
// users/users.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Load Users': emptyProps(),
'Load Users Success': props<{ users: User[] }>(),
'Load Users Failure': props<{ error: string }>(),
'Select User': props<{ id: string }>(),
'Update User': props<{ user: User }>(),
'Delete User': props<{ id: string }>(),
},
});Le reducer utilise createEntityAdapter pour gérer une collection normalisée.
// users/users.reducer.ts
import { createFeature, createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { UsersActions } from './users.actions';
export interface UsersState extends EntityState<User> {
selectedId: string | null;
loading: boolean;
error: string | null;
}
export const usersAdapter = createEntityAdapter<User>({
selectId: (u) => u.id,
sortComparer: (a, b) => a.name.localeCompare(b.name),
});
const initialState: UsersState = usersAdapter.getInitialState({
selectedId: null,
loading: false,
error: null,
});
export const usersFeature = createFeature({
name: 'users',
reducer: createReducer(
initialState,
on(UsersActions.loadUsers, (state) => ({ ...state, loading: true, error: null })),
on(UsersActions.loadUsersSuccess, (state, { users }) =>
usersAdapter.setAll(users, { ...state, loading: false }),
),
on(UsersActions.loadUsersFailure, (state, { error }) => ({ ...state, loading: false, error })),
on(UsersActions.selectUser, (state, { id }) => ({ ...state, selectedId: id })),
on(UsersActions.updateUser, (state, { user }) => usersAdapter.upsertOne(user, state)),
on(UsersActions.deleteUser, (state, { id }) => usersAdapter.removeOne(id, state)),
),
extraSelectors: ({ selectUsersState, selectSelectedId }) => {
const { selectAll, selectEntities, selectTotal } = usersAdapter.getSelectors();
return {
selectAllUsers: createSelector(selectUsersState, selectAll),
selectUserEntities: createSelector(selectUsersState, selectEntities),
selectUsersTotal: createSelector(selectUsersState, selectTotal),
selectCurrentUser: createSelector(
selectUsersState,
selectSelectedId,
(state, id) => (id ? selectEntities(state)[id] ?? null : null),
),
};
},
});
import { createSelector } from '@ngrx/store';Les effects encapsulent les appels HTTP.
// users/users.effects.ts
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, map, of, switchMap } from 'rxjs';
import { UsersActions } from './users.actions';
import { UsersApi } from './users.api';
@Injectable()
export class UsersEffects {
private readonly actions$ = inject(Actions);
private readonly api = inject(UsersApi);
readonly loadUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.loadUsers),
switchMap(() =>
this.api.list().pipe(
map((users) => UsersActions.loadUsersSuccess({ users })),
catchError((err) => of(UsersActions.loadUsersFailure({ error: err.message }))),
),
),
),
);
readonly persistUpdate$ = createEffect(
() =>
this.actions$.pipe(
ofType(UsersActions.updateUser),
switchMap(({ user }) => this.api.update(user)),
),
{ dispatch: false },
);
}Enregistrement du feature store dans un route ou dans un parent standalone.
// users.routes.ts
import { Routes } from '@angular/router';
import { provideState } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { usersFeature } from './users.reducer';
import { UsersEffects } from './users.effects';
export const usersRoutes: Routes = [
{
path: 'users',
providers: [provideState(usersFeature), provideEffects(UsersEffects)],
loadComponent: () => import('./users.page').then((m) => m.UsersPage),
},
];Consommation dans un composant avec signals via toSignal.
// users.page.ts
import { ChangeDetectionStrategy, Component, computed, inject, OnInit } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { usersFeature } from './users.reducer';
import { UsersActions } from './users.actions';
@Component({
selector: 'app-users-page',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (loading()) {
<p>Chargement…</p>
} @else if (error()) {
<p class="error">{{ error() }}</p>
} @else {
<ul>
@for (user of users(); track user.id) {
<li (click)="select(user.id)">{{ user.name }} — {{ user.email }}</li>
}
</ul>
<p>Total : {{ total() }}</p>
}
`,
})
export class UsersPage implements OnInit {
private readonly store = inject(Store);
readonly users = toSignal(this.store.select(usersFeature.selectAllUsers), { initialValue: [] });
readonly loading = toSignal(this.store.select(usersFeature.selectLoading), { initialValue: false });
readonly error = toSignal(this.store.select(usersFeature.selectError), { initialValue: null });
readonly total = toSignal(this.store.select(usersFeature.selectUsersTotal), { initialValue: 0 });
ngOnInit(): void {
this.store.dispatch(UsersActions.loadUsers());
}
select(id: string): void {
this.store.dispatch(UsersActions.selectUser({ id }));
}
}Un exemple plus avancé d'effect avec polling et cleanup automatique.
// notifications.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { interval, EMPTY, exhaustMap, takeUntil, catchError, map } from 'rxjs';
import { NotificationsActions } from './notifications.actions';
import { NotificationsApi } from './notifications.api';
@Injectable()
export class NotificationsEffects {
private readonly actions$ = inject(Actions);
private readonly api = inject(NotificationsApi);
readonly startPolling$ = createEffect(() =>
this.actions$.pipe(
ofType(NotificationsActions.startPolling),
exhaustMap(() =>
interval(30_000).pipe(
takeUntil(this.actions$.pipe(ofType(NotificationsActions.stopPolling))),
exhaustMap(() =>
this.api.fetch().pipe(
map((items) => NotificationsActions.pollSuccess({ items })),
catchError(() => EMPTY),
),
),
),
),
),
);
}Un meta-reducer pour la persistance localStorage globale.
// meta-reducers/persistence.meta-reducer.ts
import { ActionReducer, INIT, UPDATE } from '@ngrx/store';
const STORAGE_KEY = 'app-state';
export function persistenceMetaReducer<S>(reducer: ActionReducer<S>): ActionReducer<S> {
return (state, action) => {
if (action.type === INIT || action.type === UPDATE) {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
state = { ...(state as object), ...JSON.parse(saved) } as S;
} catch {
/* ignore */
}
}
}
const nextState = reducer(state, action);
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextState));
return nextState;
};
}
// app.config.ts — provideStore(reducers?, config?)
provideStore({}, { metaReducers: [persistenceMetaReducer] });⚠️ Cette version est volontairement naïve (et c'est le point de l'exercice 4) : elle
JSON.stringifytout le state à chaque action, ce qui sature le main thread dès que le state grossit, et persiste même les slices volatils (marketData, flux WS). En prod : (1) ne persistez qu'unpickdu slice ciblé, (2) debouncez l'écriture (ou n'écrivez que si la référence du slice a changé), (3) versionnez le schéma pour migrer/jeter une payload obsolète, (4)try/catchautour desetItem(quota dépassé en navigation privée). La version durcie est l'objectif de l'exercice 4.
🎯 Patterns courants
Feature state isolé via createFeature. Depuis NgRx 14, createFeature génère automatiquement les selectors de base (selectFeatureName, selectField) pour chaque clé du state. On gagne du boilerplate et on garde la type-safety. L'extraSelectors permet de composer des selectors dérivés au même endroit, ce qui localise toute la logique de lecture dans un seul fichier.
Action groups via createActionGroup. Plutôt que de déclarer chaque action avec createAction, on regroupe toutes les actions d'un même domaine. Le source devient le préfixe ([Users] Load Users) et chaque clé devient un creator. Cela élimine les chaînes magiques dispersées et facilite le refactor.
Entity adapter pour les collections. Toute liste de plus de 5 entités identifiables (avec un id) devrait passer par createEntityAdapter. Cela normalise la structure ({ ids: string[], entities: Record<id, T> }), donne des opérations CRUD O(1) (addOne, upsertMany, removeOne), et fournit des selectors prêts à l'emploi (selectAll, selectEntities). Le pattern devient la norme dès qu'on manipule des listes.
Selectors composés et projector pour la mémoïsation. Un selector doit toujours être créé via createSelector, jamais en mappant directement dans le composant. La fonction de projection (le dernier argument) n'est appelée que si l'une des références d'entrée change. On peut empiler les selectors sans coût de calcul tant que les inputs sont stables. Pour les selectors paramétrés, on retourne une factory : createSelector(selectAllUsers, (users) => (role: string) => users.filter(u => u.role === role)).
Effects qui ne dispatch pas. Certains effects ont uniquement un side-effect (persistance optimiste, télémétrie, logging) sans nouvelle action à émettre. On les marque { dispatch: false } pour éviter qu'NgRx ne renvoie un éventuel résultat dans le store.
Choix du flattening operator = décision d'architecture, pas de style. Le choix entre switchMap/exhaustMap/concatMap/mergeMap encode une sémantique métier. Tableau de décision :
| Operator | Comportement sur émission concurrente | Cas d'usage type | Anti-usage |
|---|---|---|---|
switchMap | annule la précédente, garde la dernière | recherche typeahead, navigation, reload | mutations (annule un POST en vol → perte de donnée) |
exhaustMap | ignore les nouvelles tant qu'une tourne | anti-double-submit (login, paiement) | flux où chaque event compte |
concatMap | sérialise, préserve l'ordre (file) | mutations ordonnées (réordonner une liste, append) | charges parallélisables (lent inutilement) |
mergeMap | parallélise, aucun ordre garanti | fan-out indépendant (préchargement d'images) | login/WS (empilement, race, fuites) |
// Mutations qui DOIVENT garder l'ordre : un switchMap annulerait
// le PUT précédent → état serveur incohérent. concatMap = file FIFO.
readonly reorder$ = createEffect(() =>
this.actions$.pipe(
ofType(BoardActions.moveCard),
concatMap(({ cardId, toColumn, position }) =>
this.api.move(cardId, toColumn, position).pipe(
map(() => BoardActions.moveCardSuccess({ cardId })),
catchError((e) => of(BoardActions.moveCardRevert({ cardId, error: e.message }))),
),
),
),
);Router Store pour la sync URL ↔ state. En activant provideRouterStore(), chaque navigation dispatch des actions (ROUTER_NAVIGATION, ROUTER_NAVIGATED). On peut écrire des selectors qui lisent les params de route et déclencher des effects au changement de route. C'est la solution propre pour synchroniser le state avec l'URL sans ActivatedRoute éparpillé.
Component Store pour le state local typé. Pour un wizard, un formulaire complexe, ou une feature isolée qui n'a pas besoin de DevTools globales ni d'être partagée, ComponentStore offre un mini-store scopé à la durée de vie d'un composant. L'API est plus directe (setState, patchState, updater, effect) et il n'y a pas d'actions à déclarer. Note 2026 : pour les nouveaux usages locaux, signalStore est souvent préférable.
Facades pour réduire le couplage. Plutôt que d'injecter Store directement dans 30 composants, on encapsule les sélections et dispatches dans un service UsersFacade. Le composant ne dépend plus de la structure NgRx, ce qui simplifie les refactors et les tests.
// users.facade.ts
import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { usersFeature } from './users.reducer';
import { UsersActions } from './users.actions';
@Injectable({ providedIn: 'root' })
export class UsersFacade {
private readonly store = inject(Store);
readonly users = toSignal(this.store.select(usersFeature.selectAllUsers), { initialValue: [] });
readonly loading = toSignal(this.store.select(usersFeature.selectLoading), { initialValue: false });
readonly current = toSignal(this.store.select(usersFeature.selectCurrentUser), { initialValue: null });
loadAll(): void { this.store.dispatch(UsersActions.loadUsers()); }
select(id: string): void { this.store.dispatch(UsersActions.selectUser({ id })); }
update(user: User): void { this.store.dispatch(UsersActions.updateUser({ user })); }
remove(id: string): void { this.store.dispatch(UsersActions.deleteUser({ id })); }
}Combinaison de selectors entre features. Pour des données qui croisent plusieurs feature states (ex. afficher le nom de l'utilisateur connecté dans la liste des commandes), on crée des selectors composés dans un fichier dédié cross-cutting-selectors.ts. Cela évite de polluer un feature avec des dépendances vers d'autres.
Actions « page » vs actions « API » vs actions « user ». Une convention saine distingue les sources : [Users Page] User Selected (event UI), [Users API] Users Loaded (event service), [Auth Service] Token Expired (event interne). Cela aide énormément à lire les DevTools et à comprendre d'où viennent les changements.
🔄 Versions — Angular 16 → 20
NgRx a suivi de près l'évolution d'Angular vers les signals tout en conservant son cœur Redux.
NgRx 16 (mi-2023, Angular 16) : introduction de @ngrx/signals en preview. createActionGroup et createFeature deviennent les API recommandées. Standalone APIs (provideStore, provideEffects) deviennent la norme.
NgRx 17 (fin 2023, Angular 17) : @ngrx/signals quitte la preview et propose signalStore, signalState, patchState. Meilleure intégration avec les standalone components. DevTools mis à jour.
NgRx 18 (mi-2024, Angular 18) : stabilisation du SignalStore avec les custom features (signalStoreFeature) permettant la composition. rxMethod se stabilise pour les flux async dans SignalStore. Les selectors classiques restent inchangés mais s'intègrent mieux avec toSignal.
NgRx 19 (fin 2024, Angular 19) : zoneless préparé. SignalStore devient le défaut recommandé pour les nouveaux projets de taille moyenne. NgRx classic reste la référence pour les apps existantes ou très larges.
NgRx 20 (mi-2025, Angular 20) : full zoneless support, signals partout dans les composants. Le composant store classique est officiellement déprécié en faveur du SignalStore. NgRx classic continue d'évoluer pour les besoins de DevTools, time-travel et middleware complexes.
Trajectoire 2026 : NgRx classic est positionné comme store global pour apps complexes (DevTools, replay, middleware, plusieurs équipes). SignalStore prend le rôle de store local et feature-scoped. Component Store est en phase de maintenance, pas de nouvelles features.
⚠️ Pitfalls — 6-10
1. Selectors non mémoïsés (mapping inline). Écrire store.select((s) => s.users.list.filter(u => u.active)) casse la mémoïsation : à chaque émission, la projection est ré-exécutée et une nouvelle référence est renvoyée, ce qui retrigger toutes les vues qui en dépendent. Toujours passer par createSelector et lire le résultat via store.select(selectActiveUsers).
2. Effects qui dispatchent en boucle infinie. Un effect qui écoute une action et dispatche la même action (directement ou indirectement) crée une boucle. Symptôme : le navigateur freeze, les DevTools explosent. Toujours vérifier qu'un effect émet une action différente de celle qu'il écoute, ou utiliser { dispatch: false }.
3. Side-effects dans les reducers. Un reducer doit être pur. Tout console.log, localStorage.setItem, Date.now(), Math.random(), appel HTTP, ou mutation d'objet rend le state non rejouable et casse les DevTools. Si on a besoin de persistance, on passe par un effect ou par un meta-reducer dédié.
4. Mutation directe du state. TypeScript ne protège pas du state.users.push(newUser). Cela ne déclenche aucune émission (mêmes références) et corrompt l'historique des DevTools. Toujours retourner un nouvel objet : { ...state, users: [...state.users, newUser] } ou utiliser usersAdapter.addOne(newUser, state).
5. Structure d'actions « event » vs « command ». Une action devrait décrire ce qui s'est passé (event), pas ce qu'il faut faire (command). LoadUsersSuccess est correct, SetLoadingFalse est trop technique. Le bon nommage suit le pattern [Source] Event ([Users API] Users Loaded Success). Ça améliore la lisibilité des DevTools.
6. Trop de feature stores pour rien. Découper en feature stores chaque sous-domaine sans réel besoin de lazy loading crée un overhead inutile. Garder un feature store quand : le domaine a sa propre logique métier, est lazy-loadable, ou est isolé d'une équipe.
7. Subscriptions sans cleanup. store.select(...).subscribe(...) sans takeUntilDestroyed() ou async pipe fuit la mémoire. En 2026, préférer toSignal() qui gère automatiquement le cleanup via l'injection context.
8. Oublier OnPush. NgRx fonctionne avec ChangeDetection par défaut, mais perd 80% de son intérêt. Tous les composants qui consomment du state NgRx doivent être en ChangeDetectionStrategy.OnPush.
9. Selectors avec props non mémoïsés. createSelector avec une factory paramétrée ((state, props) => ...) ne mémoïse pas correctement si les props changent à chaque appel. Préférer retourner une fonction depuis le selector ou utiliser createSelectorFactory avec un comparateur custom.
10. Effects qui dépendent du DOM ou de l'ordre d'init. Un effect qui suppose qu'un composant est monté, ou qui lit un état Angular Router avant que le router soit prêt, peut tomber dans des race conditions. Utiliser provideEffects au bon scope et withLatestFrom au lieu de combineLatest quand on veut juste lire un état sans réagir à ses changements.
Bonus — Comparaison NgRx classic vs SignalStore.
| Critère | NgRx classic | SignalStore (@ngrx/signals) |
|---|---|---|
| Mental model | Redux (actions, reducers, effects) | Service stateful composable (signals) |
| Boilerplate | Élevé (actions, reducers, effects, selectors séparés) | Minimal (tout dans signalStore) |
| Type-safety | Très bonne mais verbeuse | Excellente, inférence automatique |
| DevTools | Time-travel complet, replay d'actions | Inspection state, pas de time-travel complet |
| Async | RxJS via effects (puissant, courbe d'apprentissage) | rxMethod (RxJS encapsulé), plus direct |
| Tests | Reducers purs (faciles), effects via marble | Service classique avec TestBed |
| Cas d'usage | Apps larges, multi-équipes, audit, replay | Apps moyennes, state feature/domaine |
| Migration | Pattern bien établi | Pattern moderne 2024+ |
| Adoption 2026 | Maintenu, défaut pour apps Redux-oriented | Recommandé pour nouveaux projets |
🧪 Testing
Les tests NgRx s'articulent autour de quatre cibles : reducers (purs, faciles), selectors (purs, mémoïsation à vérifier), effects (RxJS, marble ou observer-spy), composants (mock du store).
// users.reducer.spec.ts
import { usersFeature, usersAdapter } from './users.reducer';
import { UsersActions } from './users.actions';
describe('usersReducer', () => {
const initial = usersAdapter.getInitialState({ selectedId: null, loading: false, error: null });
it('passe loading à true sur loadUsers', () => {
const next = usersFeature.reducer(initial, UsersActions.loadUsers());
expect(next.loading).toBe(true);
expect(next.error).toBeNull();
});
it('remplit les entités sur loadUsersSuccess', () => {
const users = [{ id: '1', name: 'A', email: 'a@x', role: 'admin' as const }];
const next = usersFeature.reducer(initial, UsersActions.loadUsersSuccess({ users }));
expect(next.loading).toBe(false);
expect(next.ids).toEqual(['1']);
expect(next.entities['1']?.name).toBe('A');
});
});Les selectors se testent en projection directe (sans store).
// users.selectors.spec.ts
import { usersFeature } from './users.reducer';
describe('selectCurrentUser', () => {
it('renvoie null si aucun id sélectionné', () => {
const state = { ids: ['1'], entities: { '1': {} as any }, selectedId: null, loading: false, error: null };
expect(usersFeature.selectCurrentUser.projector(state, null)).toBeNull();
});
it('renvoie l’utilisateur correspondant au selectedId', () => {
const user = { id: '1', name: 'A', email: 'a@x', role: 'admin' as const };
const state = { ids: ['1'], entities: { '1': user }, selectedId: '1', loading: false, error: null };
expect(usersFeature.selectCurrentUser.projector(state, '1')).toBe(user);
});
});Pour les effects, on utilise provideMockActions et un TestScheduler ou observer-spy.
// users.effects.spec.ts
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { UsersEffects } from './users.effects';
import { UsersActions } from './users.actions';
import { UsersApi } from './users.api';
import { subscribeSpyTo } from '@hirez_io/observer-spy';
describe('UsersEffects', () => {
let actions$: Observable<any>;
let effects: UsersEffects;
let apiMock: jasmine.SpyObj<UsersApi>;
beforeEach(() => {
apiMock = jasmine.createSpyObj('UsersApi', ['list']);
TestBed.configureTestingModule({
providers: [
UsersEffects,
provideMockActions(() => actions$),
{ provide: UsersApi, useValue: apiMock },
],
});
effects = TestBed.inject(UsersEffects);
});
it('dispatche loadUsersSuccess en cas de succès', async () => {
apiMock.list.and.returnValue(of([{ id: '1', name: 'A', email: 'a@x', role: 'admin' as const }]));
actions$ = of(UsersActions.loadUsers());
const spy = subscribeSpyTo(effects.loadUsers$);
await spy.onComplete();
expect(spy.getLastValue()).toEqual(
UsersActions.loadUsersSuccess({ users: jasmine.any(Array) as any }),
);
});
it('dispatche loadUsersFailure sur erreur', async () => {
apiMock.list.and.returnValue(throwError(() => new Error('boom')));
actions$ = of(UsersActions.loadUsers());
const spy = subscribeSpyTo(effects.loadUsers$);
await spy.onComplete();
expect(spy.getLastValue()).toEqual(UsersActions.loadUsersFailure({ error: 'boom' }));
});
});Pour les composants, on remplace le store par un MockStore.
import { provideMockStore, MockStore } from '@ngrx/store/testing';
TestBed.configureTestingModule({
providers: [
provideMockStore({
selectors: [
{ selector: usersFeature.selectAllUsers, value: [{ id: '1', name: 'A', email: 'a@x', role: 'admin' }] },
{ selector: usersFeature.selectLoading, value: false },
],
}),
],
});
const store = TestBed.inject(MockStore);
spyOn(store, 'dispatch');🎬 Cas d'usage concrets
Scénario 1 — SaaS RH, workflow ATS à état complexe (multi-équipes)
Contexte : un éditeur SaaS RH propose un module ATS (Applicant Tracking System) où chaque candidature suit un pipeline (new → screen → interview → offer → hired/rejected) avec relances automatiques, scoring IA, notes recruteur partagées en temps réel via WebSocket. Trois équipes Angular travaillent en parallèle (sourcing, entretiens, onboarding), chacune sur son feature store. Le state global agrège candidats, postes, recruteurs, slots d'entretien — facilement 30 entités liées. NgRx classic s'impose parce qu'on a besoin de time-travel debug quand un recruteur signale "j'ai perdu mes notes après avoir bougé la candidature", d'une structure d'actions sérialisables pour rejouer les bugs en QA, et d'une isolation effects pour gérer WebSocket + retries + déduplication d'optimistic updates. Les feature stores candidates/, pipelines/, interviews/ sont chacun lazy-loadés avec leur reducer et leurs effects, branchés sur le router store pour driver les redirections après mutation. Les entités passent par @ngrx/entity pour la pagination virtuelle de 50 000 candidats.
Scénario 2 — E-commerce mode, panier + checkout multi-étapes
Contexte : un retailer mode multi-pays (FR/EN/ES/IT) avec panier persistant côté client, checkout en 4 étapes (adresse, livraison, paiement, confirmation), code promo, points fidélité, et réservation temporaire de stock (15 min). Le panier doit survivre au refresh (localStorage rehydraté via meta-reducer), être synchronisé entre onglets (storage event), et tolérer la perte réseau (file d'actions différées rejouée à la reconnexion). NgRx offre exactement ces garanties : un meta-reducer rehydrateMetaReducer lit localStorage au boot, un effect syncCartAcrossTabs$ écoute window.storage, un effect replayQueuedActions$ vide la queue offline. Le state checkout est modélisé en machine à états (sélecteurs selectCanProceedToPayment calculés depuis adresse + livraison valides). Quand l'utilisateur applique un code promo, l'action applyPromo déclenche un optimistic update suivi d'un effect HTTP qui peut annuler via revertPromo si le backend refuse. La structure d'actions explicites permet aux marketers de tracker le funnel précisément via un effect analytics qui mappe chaque action vers un événement GTM.
Scénario 3 — Dashboard banque, trading state temps réel
Contexte : poste de travail trader actions/obligations avec 8 panneaux live (carnet d'ordres, positions, P&L, alertes risque, news, charts) alimentés par 3 flux WebSocket à 1000+ msg/s. Chaque message doit mettre à jour le store sans bloquer le rendu, et les composants ne doivent ré-évaluer que ce qui les concerne. NgRx classic excelle ici grâce à la mémoïsation stricte des selectors : selectPortfolioPnL ne se déclenche que si positions ou marketPrices changent. Les effects gèrent la reconnexion WebSocket avec backoff exponentiel, le throttling des updates (bufferTime(50) pour batcher les ticks), et la persistance des préférences utilisateur en IndexedDB. Le runtime checks NgRx (strictStateImmutability, strictActionImmutability) sont désactivés en prod pour la perf mais actifs en dev pour bloquer toute mutation accidentelle. Le store est partitionné : marketData (volatil, non persisté), portfolio (persisté), ui (préférences). Le time-travel debugging est désactivé en prod (sinon mémoire qui explose) mais activé en staging pour rejouer les sessions de trading après incident.
🛠️ Exemple end-to-end
Use case : feature store candidates du SaaS ATS, avec liste paginée + filtres + détail + mutation optimiste sur le pipeline.
// candidates.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Candidate, PipelineStage } from './candidate.model';
export const CandidatesActions = createActionGroup({
source: 'Candidates',
events: {
'Load Page': props<{ page: number; filters: Record<string, string> }>(),
'Load Page Success': props<{ items: Candidate[]; total: number }>(),
'Load Page Failure': props<{ error: string }>(),
'Move To Stage': props<{ id: string; stage: PipelineStage }>(),
'Move To Stage Success': props<{ id: string; stage: PipelineStage }>(),
'Move To Stage Revert': props<{ id: string; previousStage: PipelineStage; reason: string }>(),
'Select': props<{ id: string | null }>(),
'Clear Filters': emptyProps(),
},
});// candidates.reducer.ts
import { createFeature, createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { Candidate } from './candidate.model';
import { CandidatesActions } from './candidates.actions';
export interface CandidatesState extends EntityState<Candidate> {
total: number;
page: number;
filters: Record<string, string>;
selectedId: string | null;
loading: boolean;
error: string | null;
}
export const adapter = createEntityAdapter<Candidate>({ selectId: (c) => c.id });
const initial: CandidatesState = adapter.getInitialState({
total: 0, page: 1, filters: {}, selectedId: null, loading: false, error: null,
});
export const candidatesFeature = createFeature({
name: 'candidates',
reducer: createReducer(
initial,
on(CandidatesActions.loadPage, (state, { page, filters }) => ({ ...state, page, filters, loading: true, error: null })),
on(CandidatesActions.loadPageSuccess, (state, { items, total }) =>
adapter.setAll(items, { ...state, total, loading: false })),
on(CandidatesActions.loadPageFailure, (state, { error }) => ({ ...state, loading: false, error })),
on(CandidatesActions.moveToStage, (state, { id, stage }) =>
adapter.updateOne({ id, changes: { stage, pendingStage: true } }, state)),
on(CandidatesActions.moveToStageSuccess, (state, { id, stage }) =>
adapter.updateOne({ id, changes: { stage, pendingStage: false } }, state)),
on(CandidatesActions.moveToStageRevert, (state, { id, previousStage }) =>
adapter.updateOne({ id, changes: { stage: previousStage, pendingStage: false } }, state)),
on(CandidatesActions.select, (state, { id }) => ({ ...state, selectedId: id })),
),
extraSelectors: ({ selectCandidatesState, selectSelectedId }) => {
// ⚠️ On compose via createSelector pour rester mémoïsé. Une factory
// inline `(s) => adapter.getSelectors().selectAll(...)` recalculerait à
// CHAQUE émission du store et renverrait une nouvelle référence à chaque
// fois — exactement le pitfall #1 de ce chapitre.
const { selectAll, selectEntities } = adapter.getSelectors();
const selectAllCandidates = createSelector(selectCandidatesState, selectAll);
const selectCandidateEntities = createSelector(selectCandidatesState, selectEntities);
return {
selectAllCandidates,
selectSelectedCandidate: createSelector(
selectCandidateEntities,
selectSelectedId,
(entities, id) => (id ? entities[id] ?? null : null),
),
// View-model dédié : on NE renvoie PAS le slice brut (`ids`/`entities`)
// au template. On expose un objet plat `items[]` dérivé de l'adapter.
// createSelector mémoïse ce VM tant que la liste + les flags ne bougent
// pas → le `async`/`toSignal` ne re-render que sur changement réel.
selectCandidatesVm: createSelector(
selectAllCandidates,
selectCandidatesState,
(items, s) => ({ items, total: s.total, page: s.page, loading: s.loading, error: s.error }),
),
};
},
});
import { createSelector } from '@ngrx/store';// candidates.effects.ts
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of, switchMap, withLatestFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { CandidatesActions } from './candidates.actions';
import { CandidatesApi } from './candidates.api';
import { candidatesFeature } from './candidates.reducer';
export const loadPage$ = createEffect(
(actions$ = inject(Actions), api = inject(CandidatesApi)) =>
actions$.pipe(
ofType(CandidatesActions.loadPage),
switchMap(({ page, filters }) =>
api.list(page, filters).pipe(
map((res) => CandidatesActions.loadPageSuccess({ items: res.items, total: res.total })),
catchError((err) => of(CandidatesActions.loadPageFailure({ error: err.message }))),
)),
),
{ functional: true },
);
export const moveToStage$ = createEffect(
(actions$ = inject(Actions), api = inject(CandidatesApi), store = inject(Store)) =>
actions$.pipe(
ofType(CandidatesActions.moveToStage),
withLatestFrom(store.select(candidatesFeature.selectAllCandidates)),
exhaustMap(([{ id, stage }, all]) => {
const previous = all.find((c) => c.id === id)?.stage ?? 'new';
return api.updateStage(id, stage).pipe(
map(() => CandidatesActions.moveToStageSuccess({ id, stage })),
catchError((err) => of(CandidatesActions.moveToStageRevert({ id, previousStage: previous, reason: err.message }))),
);
}),
),
{ functional: true },
);// candidates-list.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { CandidatesActions } from './candidates.actions';
import { candidatesFeature } from './candidates.reducer';
@Component({
selector: 'app-candidates-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (vm$ | async; as vm) {
<table>
@for (c of vm.items; track c.id) {
<tr [class.pending]="c.pendingStage">
<td>{{ c.name }}</td>
<td>{{ c.stage }}</td>
<td>
<button (click)="move(c.id, 'interview')">Interview</button>
<button (click)="move(c.id, 'offer')">Offer</button>
</td>
</tr>
}
</table>
<button [disabled]="vm.loading" (click)="next(vm.page)">Next page</button>
}
`,
})
export class CandidatesListComponent {
private store = inject(Store);
// ✅ on lit le VM dérivé, pas le slice brut : `vm.items` est un tableau
// (selectAll de l'adapter), pas `{ ids, entities }`.
vm$ = this.store.select(candidatesFeature.selectCandidatesVm);
move(id: string, stage: 'interview' | 'offer') {
this.store.dispatch(CandidatesActions.moveToStage({ id, stage }));
}
next(page: number) {
this.store.dispatch(CandidatesActions.loadPage({ page: page + 1, filters: {} }));
}
}Flow complet : l'utilisateur clique "Interview" → moveToStage dispatché → reducer flag pendingStage: true (UI grise la ligne instantanément) → effect appelle l'API → succès dispatch moveToStageSuccess qui retire le flag, ou échec dispatch moveToStageRevert qui restaure l'ancien stage + affiche un toast. L'utilisateur n'attend jamais, et le state reste cohérent même en cas de coupure réseau.
🤖 NgRx pour une UI d'agent IA (streaming, tool-calls, Stop)
Servir/consommer un agent IA depuis Angular est un cas où NgRx classic brille : un flux de tokens SSE, des appels d'outils (tool_use) qui arrivent dans le désordre, un bouton Stop, des retries — c'est exactement le genre de logique async lourde et auditée pour laquelle Redux a été conçu. Le state d'une conversation agentique est append-only (on n'édite jamais un message passé, on en ajoute), ce qui colle parfaitement aux reducers purs et au time-travel (rejouer un bug de streaming en QA est inestimable).
Mental model. Le store côté client est le journal de bord de la conversation. Le backend (NestJS) streame des deltas (tokens, ouverture/fermeture de tool-calls) via SSE. Chaque delta devient une action
[Agent API] ..., le reducer l'applique de manière idempotente (clé =generationId), et les selectors mémoïsés ne re-rendent que le message en cours. Le modèle Anthropic ciblé estclaude-sonnet-4-6(équilibré) ouclaude-opus-4-8(raisonnement lourd) ;claude-haiku-4-5pour les sous-tâches rapides.
Modèle de state : discriminated union pour la timeline
// agent/agent.model.ts
export type ToolCallStatus = 'pending' | 'running' | 'streaming' | 'done' | 'error';
export interface ToolCall {
readonly id: string; // id Anthropic du tool_use block
readonly name: string; // ex. 'search_candidates'
readonly input: unknown; // arguments JSON (assemblés depuis les deltas)
readonly status: ToolCallStatus;
readonly result?: unknown;
readonly error?: string;
}
export type AgentStep =
| { readonly kind: 'text'; readonly id: string; readonly text: string }
| { readonly kind: 'tool'; readonly id: string; readonly call: ToolCall };
export interface AgentMessage {
readonly id: string; // generationId — clé d'idempotence
readonly role: 'user' | 'assistant';
readonly steps: AgentStep[]; // append-only
readonly status: 'streaming' | 'done' | 'stopped' | 'error';
}Actions : un delta = une action sérialisable
// agent/agent.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { AgentStep } from './agent.model';
export const AgentActions = createActionGroup({
source: 'Agent API',
events: {
// UI
'Prompt Submitted': props<{ generationId: string; prompt: string }>(),
'Stop Requested': props<{ generationId: string }>(),
// Flux SSE (chaque delta est rejouable et idempotent par generationId)
'Text Delta': props<{ generationId: string; stepId: string; chunk: string }>(),
'Tool Call Started': props<{ generationId: string; stepId: string; toolId: string; name: string }>(),
'Tool Input Delta': props<{ generationId: string; stepId: string; partialJson: string }>(),
'Tool Result': props<{ generationId: string; stepId: string; result: unknown }>(),
'Generation Done': props<{ generationId: string }>(),
'Generation Failed': props<{ generationId: string; error: string }>(),
'Generation Stopped': props<{ generationId: string }>(),
},
});Reducer : append-only + idempotence sur le generationId
Le piège d'un flux SSE, c'est la reconnexion : EventSource peut rejouer les derniers événements. Le reducer doit donc être idempotent — appliquer deux fois textDelta ne doit pas dupliquer le texte si on connait le dernier offset. Stratégie simple et robuste : le serveur envoie un seq monotone par generationId, le state garde lastSeq, et tout delta avec seq <= lastSeq est ignoré. (Omis ici pour la lisibilité ; en prod, mettez seq dans chaque action.)
// agent/agent.reducer.ts
import { createFeature, createReducer, on } from '@ngrx/store';
import { createEntityAdapter, EntityState } from '@ngrx/entity';
import { AgentMessage } from './agent.model';
import { AgentActions } from './agent.actions';
export const msgAdapter = createEntityAdapter<AgentMessage>();
export interface AgentState extends EntityState<AgentMessage> {
activeGenerationId: string | null;
}
const initial = msgAdapter.getInitialState({ activeGenerationId: null });
// Helper pur : applique un chunk de texte au step courant (immuable).
function appendText(msg: AgentMessage, stepId: string, chunk: string): AgentMessage {
const steps = msg.steps.some((s) => s.id === stepId)
? msg.steps.map((s) =>
s.id === stepId && s.kind === 'text' ? { ...s, text: s.text + chunk } : s,
)
: [...msg.steps, { kind: 'text' as const, id: stepId, text: chunk }];
return { ...msg, steps };
}
export const agentFeature = createFeature({
name: 'agent',
reducer: createReducer(
initial,
on(AgentActions.promptSubmitted, (state, { generationId, prompt }) =>
msgAdapter.addMany(
[
{ id: `${generationId}:user`, role: 'user', status: 'done', steps: [{ kind: 'text', id: 'u', text: prompt }] },
{ id: generationId, role: 'assistant', status: 'streaming', steps: [] },
],
{ ...state, activeGenerationId: generationId },
),
),
on(AgentActions.textDelta, (state, { generationId, stepId, chunk }) => {
const msg = state.entities[generationId];
if (!msg || msg.status !== 'streaming') return state; // idempotence défensive
return msgAdapter.setOne(appendText(msg, stepId, chunk), state);
}),
on(AgentActions.generationDone, (state, { generationId }) =>
msgAdapter.mapOne(
{ id: generationId, map: (m) => ({ ...m, status: 'done' }) },
{ ...state, activeGenerationId: null },
),
),
on(AgentActions.generationStopped, (state, { generationId }) =>
msgAdapter.mapOne(
{ id: generationId, map: (m) => ({ ...m, status: 'stopped' }) },
{ ...state, activeGenerationId: null },
),
),
),
});Effect : SSE + AbortController câblé au Stop (annule client ET serveur)
C'est le cœur. switchMap annule le flux précédent si un nouveau prompt arrive, et takeUntil(stopRequested) déclenche l'AbortController — qui ferme la requête fetch, ce qui côté NestJS lève req.on('close') et stoppe la génération Anthropic (économie de tokens réelle). On n'utilise PAS EventSource ici car il ne supporte ni POST ni headers d'auth ni AbortSignal proprement — on lit le ReadableStream à la main.
// agent/agent.effects.ts
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Observable, switchMap, takeUntil, filter } from 'rxjs';
import { AgentActions } from './agent.actions';
import { Action } from '@ngrx/store';
/** Lit un flux SSE NestJS et émet des actions NgRx. */
function streamAgent(generationId: string, prompt: string, signal: AbortSignal): Observable<Action> {
return new Observable<Action>((subscriber) => {
(async () => {
try {
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ generationId, prompt }),
signal,
});
if (!res.body) throw new Error('No stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Découpe les events SSE (séparés par \n\n)
let idx: number;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, idx).replace(/^data: /, '');
buffer = buffer.slice(idx + 2);
const evt = JSON.parse(raw) as { type: string; [k: string]: unknown };
if (evt.type === 'text') {
subscriber.next(AgentActions.textDelta({ generationId, stepId: evt.stepId as string, chunk: evt.chunk as string }));
} else if (evt.type === 'done') {
subscriber.next(AgentActions.generationDone({ generationId }));
}
// ... tool_call_started, tool_input_delta, tool_result
}
}
subscriber.complete();
} catch (err) {
if ((err as Error).name === 'AbortError') {
subscriber.next(AgentActions.generationStopped({ generationId }));
subscriber.complete();
} else {
subscriber.next(AgentActions.generationFailed({ generationId, error: (err as Error).message }));
subscriber.complete();
}
}
})();
});
}
export const runAgent$ = createEffect(
(actions$ = inject(Actions)) =>
actions$.pipe(
ofType(AgentActions.promptSubmitted),
// switchMap : un nouveau prompt annule le streaming en cours.
switchMap(({ generationId, prompt }) => {
const ac = new AbortController();
return streamAgent(generationId, prompt, ac.signal).pipe(
// Stop ciblé sur CE generationId → abort fetch → close serveur.
takeUntil(
actions$.pipe(
ofType(AgentActions.stopRequested),
filter((a) => a.generationId === generationId),
// side-effect contrôlé : on déclenche l'abort hors du reducer.
switchMap((a) => { ac.abort(); return [a]; }),
),
),
);
}),
),
{ functional: true },
);Rendu zoneless : rAF-coalescing des deltas
Sous zoneless (Angular 20), chaque textDelta met à jour un signal. À 80 tokens/s, dispatcher + re-render à chaque token est gaspilleur. On coalesce : le composant lit le message courant via toSignal, mais on bufferise l'affichage avec requestAnimationFrame pour ne peindre qu'une fois par frame. Le markdown est rendu via marked puis assaini avec DomSanitizer (jamais innerHTML brut sur de la sortie LLM — injection possible).
// agent-message.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { marked } from 'marked';
import { AgentMessage } from './agent.model';
@Component({
selector: 'app-agent-message',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div class="md" [innerHTML]="html()"></div>`,
})
export class AgentMessageComponent {
private readonly sanitizer = inject(DomSanitizer);
readonly message = input.required<AgentMessage>();
readonly text = computed(() =>
this.message().steps.filter((s) => s.kind === 'text').map((s) => (s as { text: string }).text).join(''),
);
// bypassSecurityTrust seulement après marked + un sanitizer DOM côté serveur idéalement.
readonly html = computed(() => this.sanitizer.sanitize(1, marked.parse(this.text()) as string) ?? '');
}Côté NestJS (rappel architecture). Le client LLM est injecté via
forRootAsync(jamaisnew Anthropic()dans un champ), le endpoint/agent/streamrenvoie unObservable/SSE,req.on('close')propage l'annulation, et les jobs lourds passent par BullMQ avec idempotence keyée sur legenerationId, retries cost-aware (ne pas re-streamer 4000 tokens déjà émis : reprendre au dernierseq), et un cost-guard + rate-limit à l'edge. Le SDK Anthropic gère les retries réseau ; vous gérez l'idempotence métier. Détails dans le chapitre NestJS correspondant.
🔁 Quand utiliser / éviter
| Utiliser NgRx classic quand | Éviter NgRx classic quand |
|---|---|
| L'app a un state global complexe consulté par plusieurs features non liées | Le state est purement local à un composant ou à un wizard |
| Time-travel debugging et DevTools sont critiques pour le support | L'app est petite (moins de 10 routes, 1 dev) |
| Plusieurs équipes contribuent et ont besoin d'un contrat clair (actions) | Tout le state tient dans 2-3 services avec signals |
| Logique async lourde (sagas, retries, polling, WebSocket) | On a juste besoin de BehaviorSubject ou signal() |
| Persistance, undo/redo, replay de bug reports | On démarre un nouveau projet 2026 → préférer SignalStore |
| Audit trail des changements d'état (logs structurés) | Le boilerplate freine la vélocité de l'équipe |
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Faites-les dans l'ordre, chacun s'appuie sur le précédent.
Exercice 1 — Optimistic update avec rollback typé.Objectif : implémenter toggleFavorite sur un feature store articles avec mise à jour optimiste et rollback en cas d'échec API. Indice/Solution : trois actions (toggleFavorite, toggleFavoriteSuccess, toggleFavoriteRevert). Le reducer flippe isFavorite immédiatement via adapter.updateOne. L'effect lit l'état précédent avec withLatestFrom(store.select(selectArticleEntities)) AVANT l'appel, et sur erreur dispatch toggleFavoriteRevert({ id, previous }). Piège : ne lisez pas l'état après le flip, sinon le rollback restaure la mauvaise valeur.
Exercice 2 — Selector paramétré correctement mémoïsé.Objectif : exposer selectArticlesByTag(tag: string) qui ne casse PAS la mémoïsation quand plusieurs composants demandent des tags différents. Indice/Solution : un createSelector global qui retourne une factory (articles) => (tag) => articles.filter(...) ne mémoïse que le tableau, pas le filtre. La vraie solution : createSelector avec props est mémoïsé sur la dernière combinaison d'args seulement. Pour N composants simultanés, utilisez createSelectorFactory + un cache LRU, ou (plus simple en 2026) un computed() côté composant qui dérive de selectAllArticles. Mesurez : mettez un console.count dans le projector et vérifiez qu'il n'explose pas au scroll.
Exercice 3 — WebSocket dans un effect avec reconnexion backoff.Objectif : effect connectFeed$ qui ouvre un WebSocket, dispatch feedMessage par message, et se reconnecte avec backoff exponentiel plafonné (max 30s) sur déconnexion, jusqu'à disconnectFeed. Indice/Solution : webSocket() de rxjs/webSocket dans un switchMap, retry({ delay: (_, n) => timer(Math.min(1000 * 2 ** n, 30_000)) }), takeUntil(ofType(disconnectFeed)). Piège classique : mergeMap au lieu de switchMap/exhaustMap → connexions WS empilées. Testez la reconnexion en coupant le réseau dans les DevTools.
Exercice 4 (prod-grade) — Meta-reducer de rehydratation sélective et versionnée.Objectif : persister UNIQUEMENT le slice ui dans localStorage, avec un numéro de version de schéma et une migration si la version stockée est obsolète. Indice/Solution : un meta-reducer qui, sur INIT/UPDATE, lit { version, ui } depuis le storage ; si version !== CURRENT, applique une fn de migration (ou jette). N'écrivez PAS tout le state (le slice marketData volatil ne doit jamais être persisté) : utilisez un pick. Piège : JSON.stringify à chaque action sur un gros state tue le main thread — debounce l'écriture ou écrivez seulement quand le slice ui change (comparez les références).
Exercice 5 (break-then-fix) — La boucle infinie d'effects.Objectif : écrivez délibérément un effect qui écoute loadUsersSuccess et dispatch loadUsers « pour rafraîchir », observez le freeze, puis corrigez-le sans perdre la fonctionnalité de refresh. Indice/Solution : le symptôme est un store qui boucle (DevTools saturées). Fix : ne re-déclenchez jamais en réaction au succès de la même chaîne. Utilisez une action UI distincte ([Users Page] Refresh Requested) ou un interval dédié takeUntil(navigationAway). Leçon staff : tout effect doit émettre une action structurellement différente de celles qui le déclenchent, ou être { dispatch: false }.
Exercice 6 (architecture) — Streaming agent IA dans le store.Objectif : implémentez la timeline agent de la section IA : reducer append-only idempotent (avec seq), effect SSE + AbortController câblé à stopRequested, et un selector selectActiveMessage qui ne re-render que le message en cours. Indice/Solution : la clé est l'idempotence : gardez lastSeq par generationId et ignorez tout delta seq <= lastSeq (gère le replay SSE à la reconnexion). Vérifiez que ac.abort() ferme bien la requête (Network tab → requête canceled) et que le serveur reçoit close. Piège mémoire : sans cap sur le nombre de messages, une conversation longue gonfle le store — ajoutez une rétention (garder N derniers, archiver le reste hors store).
🎤 En entretien
Q : Pourquoi un reducer doit-il être pur, concrètement — qu'est-ce que ça achète ? R : La pureté rend le state rejouable : (state, action) => state déterministe permet le time-travel des DevTools, le replay de bug reports en QA, et des tests triviaux sans mock. Un Date.now() ou un localStorage.setItem dans un reducer casse les trois d'un coup — ces side-effects vont dans un effect ou un meta-reducer.
Q : switchMap vs mergeMap vs concatMap vs exhaustMap dans un effect — comment choisis-tu ? R : switchMap annule la requête précédente (recherche, navigation — on veut le dernier) ; exhaustMap ignore les nouvelles tant qu'une est en cours (anti-double-submit d'un bouton) ; concatMap sérialise et préserve l'ordre (mutations qui dépendent l'une de l'autre) ; mergeMap parallélise sans garantie d'ordre (rarement le bon défaut, risque de race et de fuites WS). Le piège : mergeMap sur un login → requêtes empilées si l'utilisateur double-clique.
Q : Comment un selector paramétré peut-il casser la mémoïsation, et comment l'éviter ? R : createSelector ne mémoïse que la dernière combinaison d'arguments. Si deux composants l'appellent avec des props différentes en alternance, chaque appel invalide le cache → recalcul permanent. Solutions : createSelectorFactory avec un cache custom, une factory par composant (makeSelectById()), ou dériver côté composant via computed() à partir d'un selector non-paramétré stable.
Q : NgRx classic ou SignalStore en 2026 pour un nouveau projet — comment tranches-tu ? R : SignalStore par défaut (moins de boilerplate, signals-first, zoneless-friendly). Je bascule sur NgRx classic quand j'ai un besoin dur de : time-travel/replay pour le support, contrat d'actions sérialisables entre plusieurs équipes, ou logique async lourde et auditée (WebSocket à fort débit, sagas, queue offline). Le discriminant n'est pas la taille de l'app mais la valeur du journal d'actions : si rejouer la séquence d'événements a une valeur métier (audit, debug prod, undo), NgRx classic.
Q : Tu streames un agent IA (tokens SSE) dans le store — comment garantis-tu l'idempotence et où câbles-tu le Stop ? R : Idempotence par seq monotone par generationId : le reducer garde lastSeq et ignore tout delta seq <= lastSeq, ce qui absorbe le replay d'EventSource à la reconnexion sans dupliquer le texte. Le state est append-only (on ajoute des steps, on n'édite jamais un message passé) → colle parfaitement aux reducers purs et au time-travel. Le Stop est un AbortController déclenché par takeUntil(stopRequested filtré sur le generationId) dans le switchMap de l'effect : ac.abort() ferme le fetch, ce qui propage req.on('close') côté NestJS et coupe la génération Anthropic — annulation client ET serveur, donc des tokens réellement économisés. Le rendu se coalesce en requestAnimationFrame sous zoneless pour ne pas peindre à chaque token.
Q : strictStateSerializability est activé et ton state contient un Date — que se passe-t-il, et pourquoi ce check existe ? R : En dev, NgRx lève une erreur immédiate (Detected unserializable state). Le check existe parce qu'un state non sérialisable casse silencieusement deux choses en prod : le time-travel des DevTools (qui sérialise l'historique) et la réhydratation localStorage (JSON.parse reconstruit un objet nu, pas une instance de Date — toutes les méthodes disparaissent). La règle staff : le state ne contient que des primitives et des objets plats (date en ISO string, map en Record), et on reconstruit les objets riches côté selector. On laisse les runtime checks ON en dev (coût du deep-freeze acceptable) et OFF en prod (perf).
🔗 Liens
- Documentation officielle NgRx : https://ngrx.io
- NgRx Entity : https://ngrx.io/guide/entity
- NgRx Effects : https://ngrx.io/guide/effects
- NgRx Router Store : https://ngrx.io/guide/router-store
- Mike Ryan & Brandon Roberts — talks NgRx (ng-conf, Angular Connect)
- Tim Deschryver — articles NgRx et SignalStore (timdeschryver.dev)
- Comparatif NgRx vs SignalStore (ngrx.io/guide/signals/comparison)