Comparez les approches de state management Angular (NgRx, ComponentStore, Signal Store) et choisissez la bonne selon votre contexte.
État local avec Signals
Pour la majorité des écrans Angular, les Signals suffisent largement. Ils gèrent l'état local d'un composant ou d'un service de feature sans la verbosité de NgRx.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-panier',
standalone: true,
template: `
<p>Articles : {{ nbArticles() }}</p>
<p>Total : {{ total() | currency:'EUR' }}</p>
<button (click)="vider()">Vider</button>
`
})
export class PanierComponent {
readonly articles = signal<{ nom: string; prix: number }[]>([]);
readonly nbArticles = computed(() => this.articles().length);
readonly total = computed(() =>
this.articles().reduce((sum, a) => sum + a.prix, 0)
);
vider(): void { this.articles.set([]); }
}
Quand passer à NgRx
NgRx apporte de la valeur dans des situations précises. Avant de l'adopter, vérifie que tu as au moins 2–3 de ces critères :
- État partagé global — plusieurs routes/modules distants ont besoin du même état (ex: utilisateur connecté, panier, permissions).
- Flux d'événements complexes — effets en cascade, optimistic updates, synchronisation temps réel.
- Debug temporel — besoin du Redux DevTools pour rejouer les actions et inspecter l'historique d'état.
- Équipe distribuée — les conventions strictes de NgRx (actions nommées, reducers purs) aident à maintenir la cohérence sur une grande équipe.
- Tests exhaustifs des effets — les Effects NgRx sont facilement testables de manière isolée.
NgRx Store : vue d'ensemble
L'architecture NgRx classique suit le pattern Redux : l'état est unique, immuable et centralisé. Les 4 pièces du puzzle sont State, Reducer, Effect et Selector — chacune a une responsabilité unique.
| Concept | Rôle | Fichier type |
|---|---|---|
| State | Modèle de données + valeurs initiales | articles.state.ts |
| Action | Événement nommé décrivant ce qui s'est passé | articles.actions.ts |
| Reducer | Fonction pure State + Action → nouveau State | articles.reducer.ts |
| Effect | Side effect asynchrone (HTTP, WS, localStorage) | articles.effects.ts |
| Selector | Lecture mémorisée et dérivée du Store | articles.selectors.ts |
// actions/articles.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Article } from '../models/article.model';
export const ArticlesActions = createActionGroup({
source: 'Articles',
events: {
'Load Articles': emptyProps(),
'Load Articles Success': props<{ articles: Article[] }>(),
'Load Articles Failure': props<{ error: string }>(),
'Select Article': props<{ id: number }>(),
}
});
createActionGroup à createAction individuel — toutes les actions d'un domaine sont groupées, le typage est automatique et le source name garantit l'unicité dans les DevTools.
State : structure et immutabilité
Le State est l'unique source de vérité d'un domaine. En NgRx, il est toujours un objet TypeScript sérialisable, immutable et initialisé avec des valeurs par défaut explicites.
La structure d'un state bien typé distingue les données, l'état de chargement et les erreurs :
// state/articles.state.ts
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Article } from '../models/article.model';
// EntityState<T> fournit ids[] + entities{} — évite les tableaux plats
export interface ArticlesState extends EntityState<Article> {
selectedId: number | null;
loading: boolean;
error: string | null;
}
// L'adapter génère les opérations CRUD immuables (addOne, upsertMany, removeOne...)
export const articlesAdapter: EntityAdapter<Article> = createEntityAdapter<Article>({
selectId: (article) => article.id,
sortComparer: (a, b) => b.date.localeCompare(a.date) // tri décroissant
});
// initialState de référence
export const initialArticlesState: ArticlesState = articlesAdapter.getInitialState({
selectedId: null,
loading: false,
error: null,
});
@ngrx/entity normalise les collections en dictionnaire { ids: [], entities: {} }. Recherche par id en O(1), pas de doublons, mise à jour atomique. À utiliser dès que le state contient une liste d'entités.
Règles d'un State NgRx sain :
- Toutes les propriétés doivent être sérialisables (pas de
Date,Map,Set, classes) - Ne jamais muter l'état directement — toujours retourner un nouvel objet via le reducer
- Séparer l'état de chargement (
loading) des données (articles) et des erreurs (error) - Normaliser les collections imbriquées (éviter les tableaux d'objets avec relations)
Reducer : fonctions pures et transitions d'état
Un Reducer est une fonction pure : (state, action) → newState. Il ne produit jamais d'effets de bord, ne fait jamais d'appels HTTP et retourne toujours un nouvel objet sans muter le state en entrée.
// reducers/articles.reducer.ts
import { createFeature, createReducer, on } from '@ngrx/store';
import { ArticlesActions } from '../actions/articles.actions';
import { articlesAdapter, initialArticlesState, ArticlesState } from '../state/articles.state';
const articlesReducer = createReducer(
initialArticlesState,
// Début du chargement : reset de l'erreur + flag loading
on(ArticlesActions.loadArticles, (state): ArticlesState =>
({ ...state, loading: true, error: null })
),
// Succès : utilise l'adapter pour insérer toutes les entités d'un coup
on(ArticlesActions.loadArticlesSuccess, (state, { articles }): ArticlesState =>
articlesAdapter.setAll(articles, { ...state, loading: false })
),
// Erreur : conserve les données existantes, enregistre le message
on(ArticlesActions.loadArticlesFailure, (state, { error }): ArticlesState =>
({ ...state, loading: false, error })
),
// Sélection simple : mise à jour d'un seul champ
on(ArticlesActions.selectArticle, (state, { id }): ArticlesState =>
({ ...state, selectedId: id })
),
);
// createFeature génère automatiquement les selectors de base
// selectArticlesState, selectIds, selectEntities, selectAll, selectTotal
export const articlesFeature = createFeature({
name: 'articles',
reducer: articlesReducer,
// Selectors supplémentaires déclarés ici (Angular 16+)
extraSelectors: ({ selectSelectedId, selectEntities }) => ({
selectSelectedArticle: createSelector(
selectSelectedId,
selectEntities,
(id, entities) => (id !== null ? entities[id] ?? null : null)
)
})
});
addOne, addMany, setOne, setAll, upsertOne, upsertMany, updateOne, removeOne, removeAll. Chacune retourne un nouveau state sans muter l'original.
Enregistrement dans app.config.ts :
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { articlesFeature } from './features/articles/reducers/articles.reducer';
export const appConfig: ApplicationConfig = {
providers: [
provideStore(), // Store racine
provideState(articlesFeature), // Feature state
provideEffects([ArticlesEffects]), // Effects associés
provideStoreDevtools({ maxAge: 25 }), // Redux DevTools
]
};
Effects : orchestrer les opérations asynchrones
Les Effects gèrent tout ce qui est asynchrone ou produit des side effects : appels HTTP, WebSockets, accès au localStorage, navigation programmatique. Ils écoutent le flux d'actions, font le travail, et dispatchent une nouvelle action en résultat.
// effects/articles.effects.ts
import { inject, Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { HttpClient } from '@angular/common/http';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { ArticlesActions } from '../actions/articles.actions';
import { Article } from '../models/article.model';
@Injectable()
export class ArticlesEffects {
private actions$ = inject(Actions);
private http = inject(HttpClient);
// loadArticles$ écoute l'action LoadArticles...
loadArticles$ = createEffect(() =>
this.actions$.pipe(
ofType(ArticlesActions.loadArticles),
// exhaustMap : ignore les nouvelles actions si une requête est déjà en cours
exhaustMap(() =>
this.http.get<Article[]>('/api/articles').pipe(
// Succès : dispatche LoadArticlesSuccess avec les données
map(articles => ArticlesActions.loadArticlesSuccess({ articles })),
// Erreur : ne jamais laisser crasher l'effect — catchError obligatoire
catchError(err =>
of(ArticlesActions.loadArticlesFailure({ error: err.message }))
)
)
)
)
);
// Effect de navigation après une action (non-dispatch)
redirectAfterCreate$ = createEffect(() =>
this.actions$.pipe(
ofType(ArticlesActions.loadArticlesSuccess),
tap(() => this.router.navigate(['/articles']))
),
{ dispatch: false } // ← ne dispatche pas de nouvelle action
);
}
—
switchMap : annule la requête précédente (recherche, autocomplete)—
concatMap : attend la fin de chaque requête (séquence ordonnée)—
mergeMap : exécute en parallèle (actions indépendantes)—
exhaustMap : ignore les nouvelles tant que la courante tourne (submit unique)
Patterns avancés : Effect avec plusieurs actions de résultat :
// Effect qui dispatche plusieurs actions selon le résultat
createOrder$ = createEffect(() =>
this.actions$.pipe(
ofType(OrderActions.createOrder),
switchMap(({ order }) =>
this.orderService.create(order).pipe(
// switchMap peut retourner un tableau d'actions
switchMap(result => [
OrderActions.createOrderSuccess({ order: result }),
CartActions.clearCart(), // vide le panier
NotifActions.show({ message: 'Commande créée !' })
]),
catchError(err => of(OrderActions.createOrderFailure({ error: err.message })))
)
)
)
);
Selectors : lecture mémorisée de l'état
Les Selectors sont des fonctions mémorisées qui extraient et transforment des données depuis le Store. Ils ne recalculent que si leurs entrées changent — c'est une optimisation de performance critique dans les apps NgRx.
// selectors/articles.selectors.ts
import { createSelector } from '@ngrx/store';
import { articlesFeature, articlesAdapter } from '../reducers/articles.reducer';
// selectors générés automatiquement par createFeature :
// articlesFeature.selectArticlesState — slice racine
// articlesFeature.selectLoading — état de chargement
// articlesFeature.selectError — erreur
// articlesFeature.selectSelectedId — id sélectionné
// Selectors EntityAdapter — lire depuis le dictionnaire normalisé
const { selectAll, selectEntities, selectTotal } =
articlesAdapter.getSelectors(articlesFeature.selectArticlesState);
export const selectAllArticles = selectAll;
export const selectArticleCount = selectTotal;
// Selector composé : filtrer par catégorie
export const selectArticlesByCategory = (category: string) =>
createSelector(selectAll, (articles) =>
articles.filter(a => a.category === category)
);
// Selector composé : croiser deux slices du Store
export const selectArticleWithDetails = createSelector(
articlesFeature.selectSelectedId,
selectEntities,
(id, entities) => (id ? entities[id] ?? null : null)
);
// Selector avec projection multiple (ex: vm ViewModel)
export const selectArticlesVM = createSelector(
selectAll,
articlesFeature.selectLoading,
articlesFeature.selectError,
(articles, loading, error) => ({ articles, loading, error })
);
Utilisation dans un composant (avec selectSignal pour les Signals ou select pour les Observables) :
import { Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectArticlesVM, selectArticlesByCategory } from '../selectors/articles.selectors';
import { ArticlesActions } from '../actions/articles.actions';
@Component({
standalone: true,
template: `
@if (vm().loading) { <p>Chargement...</p> }
@if (vm().error) { <p class="text-danger">{{ vm().error }}</p> }
@for (article of vm().articles; track article.id) {
<p>{{ article.name }}</p>
}
`
})
export class ArticlesComponent {
private store = inject(Store);
// selectSignal : selector → Signal (Angular 16+)
readonly vm = this.store.selectSignal(selectArticlesVM);
// Selector avec paramètre (factory pattern)
readonly frontArticles = this.store.selectSignal(
selectArticlesByCategory('front')
);
ngOnInit(): void {
this.store.dispatch(ArticlesActions.loadArticles());
}
}
createSelector mémorise le dernier résultat. Si selectAll et selectLoading n'ont pas changé, selectArticlesVM retourne la même référence sans recalcul — le composant ne se re-rend pas.
NgRx Signal Store
Introduit avec NgRx 17, le Signal Store est une alternative moderne qui remplace la verbosité Redux par une API basée sur les Signals Angular. Il est idéal pour l'état au niveau d'une feature.
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tapResponse } from '@ngrx/operators';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { switchMap } from 'rxjs';
type ArticlesState = {
articles: Article[];
loading: boolean;
error: string | null;
};
export const ArticlesStore = signalStore(
{ providedIn: 'root' },
withState<ArticlesState>({ articles: [], loading: false, error: null }),
withComputed(({ articles }) => ({
count: computed(() => articles().length)
})),
withMethods((store, http = inject(HttpClient)) => ({
loadArticles: rxMethod<void>(
switchMap(() => {
patchState(store, { loading: true });
return http.get<Article[]>('/api/articles').pipe(
tapResponse({
next: (articles) => patchState(store, { articles, loading: false }),
error: (err: Error) => patchState(store, { error: err.message, loading: false })
})
);
})
)
}))
);
Utilisation dans un composant :
@Component({
providers: [ArticlesStore], // ou omis si providedIn: 'root'
template: `
@if (store.loading()) { <p>Chargement...</p> }
@for (a of store.articles(); track a.id) { <p>{{ a.name }}</p> }
`
})
export class ArticlesComponent {
readonly store = inject(ArticlesStore);
ngOnInit(): void { this.store.loadArticles(); }
}
Framework de décision
Choisis ta solution en fonction du périmètre et de la complexité :
- Signals (local) — état d'un composant ou d'une feature isolée. Pas de partage inter-routes.
- Signal Store (feature) — état d'une feature complète (ex: panier, profil), partagé entre quelques composants. API moderne, moins verbeux que NgRx classique.
- NgRx Store (global) — état vraiment global (auth, notifications, config), effets complexes, besoin du Redux DevTools, grande équipe.
Stratégie de migration
Si tu migres d'un service Angular classique (BehaviorSubject) vers NgRx ou Signal Store, procède feature par feature sans big bang.
- Identifier les domaines fonctionnels avec état partagé (auth, catalogue, panier…).
- Migrer un domaine à la fois — les deux systèmes coexistent sans problème pendant la transition.
- Commencer par le domaine le plus simple pour valider le pattern avant de l'étendre.
- Pour NgRx classique → Signal Store : remplacer
createFeature+ selectors parsignalStore+withState. - Écrire les tests unitaires du nouveau store avant la migration pour garantir la parité de comportement.
- Supprimer le code legacy uniquement quand les tests passent et que l'équipe valide.