Angular State Management : NgRx et Signal Store

🏷️ Front-end 📅 14/04/2026 01:20:00 👤 Mezgani said
Angular Ngrx Signal Store State Management Rxjs
Angular State Management : NgRx et Signal Store

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([]); }
}
Règle pratique: tant que l'état n'est partagé qu'entre un composant parent et ses enfants directs, reste sur Signals. Ajoute une couche de state management seulement quand plusieurs features non liées ont besoin du même état.

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
StateModèle de données + valeurs initialesarticles.state.ts
ActionÉvénement nommé décrivant ce qui s'est passéarticles.actions.ts
ReducerFonction pure State + Action → nouveau Statearticles.reducer.ts
EffectSide effect asynchrone (HTTP, WS, localStorage)articles.effects.ts
SelectorLecture mémorisée et dérivée du Storearticles.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 }>(),
    }
});
Bonne pratique : Préférer 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,
});
À retenir : @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)
        )
    })
});
Méthodes de l'EntityAdapter : 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
    );
}
Quel opérateur choisir ?
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());
    }
}
Mémoisation : 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.
Anti-pattern à éviter: mettre tout l'état dans NgRx par défaut. Une liste filtrée localement n'a pas besoin d'actions Redux — ça alourdit le code sans apporter de valeur.

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 par signalStore + 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.