Angular

État applicatif

NgRx Store et Effects, NgRx Signal Store, et lequel choisir.

Vérifié en septembre 2026 · Angular 22.1 · environ 16 min

Tout composant a un état ; ce cours traite de celui qui ne lui appartient pas. Un panier affiché dans l'en-tête et modifié depuis une fiche produit, un utilisateur connecté que lisent dix écrans, des résultats de recherche qu'on veut retrouver après un retour en arrière : ces valeurs vivent hors des composants, et il faut décider qui a le droit de les changer. Angular suffit à le faire avec un service et des signals. NgRx, bibliothèque tierce, en propose deux formalisations : le Store, dans la lignée de Redux, avec ses actions, reducers, selectors et effects, et le Signal Store, bâti directement sur les signals. Les exemples suivent NgRx 22.0.1, publié le 10 septembre 2026, dont les paquets exigent @angular/core 22. Le service vient en premier, parce que c'est l'étalon auquel les deux autres se mesurent.

Local, partagé, global

L'état propre à un composant vit dans ses signals, dont le cours Signals donne les règles. Dès que deux composants sans lien de parenté lisent la même valeur, la faire transiter par des input et des output à travers tout l'arbre devient une plomberie fragile. L'état monte alors dans un service, dont Angular garantit l'unicité à la portée où il est fourni. Fourni à la racine, il est global et vit autant que l'application. Fourni dans les providers d'un composant, il est partagé par ce composant et ses descendants, et détruit avec lui. Fourni par une route, il est partagé par cette route et ses enfants mais survit par défaut à la navigation : la destruction des injecteurs de route inutilisés est une option expérimentale depuis Angular 21.1, withExperimentalAutoCleanupInjectors().

Angular 22 ajoute @Service, qui écrit le cas racine en une ligne : l'équivalent de @Injectable({ providedIn: 'root' }), limité à l'injection par inject(). L'option autoProvided: false le retire de la racine pour le fournir soi-même à une portée plus étroite.

Un service qui expose son signal écrivable semble partager l'état ; il partage en réalité le droit de l'écrire. Ici, deux composants ajoutent le même article selon deux règles différentes, et le service n'a aucun moyen de trancher :

import { ChangeDetectionStrategy, Component, inject, input, Service, signal } from '@angular/core';

export interface Ligne {
  readonly reference: string;
  readonly quantite: number;
}

@Service()
export class Panier {
  // Le signal lui-meme est public : chaque composant qui l'injecte peut y
  // ecrire, et chacun y ecrit avec sa propre idee de ce qu'est un ajout.
  readonly lignes = signal<readonly Ligne[]>([]);
}

@Component({
  selector: 'app-fiche-produit',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button type="button" (click)="ajouter()">Ajouter au panier</button>`,
})
export class FicheProduit {
  readonly reference = input.required<string>();
  private readonly panier = inject(Panier);

  protected ajouter(): void {
    // Ici, une reference deja presente voit sa quantite augmenter...
    const reference = this.reference();
    this.panier.lignes.update((lignes) =>
      lignes.some((l) => l.reference === reference)
        ? lignes.map((l) => (l.reference === reference ? { ...l, quantite: l.quantite + 1 } : l))
        : [...lignes, { reference, quantite: 1 }],
    );
  }
}

@Component({
  selector: 'app-suggestion',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<button type="button" (click)="ajouter()">Ajouter aussi</button>`,
})
export class Suggestion {
  readonly reference = input.required<string>();
  private readonly panier = inject(Panier);

  protected ajouter(): void {
    // ...la, elle recoit une seconde ligne. Le panier affiche deux fois le
    // meme article, et rien dans le service ne pouvait l'empecher.
    this.panier.lignes.update((lignes) => [
      ...lignes,
      { reference: this.reference(), quantite: 1 },
    ]);
  }
}

La correction garde le signal écrivable dans la classe et ne publie qu'une vue en lecture seule. Toute transition passe par une méthode nommée, seul endroit où la règle existe.

import { ChangeDetectionStrategy, Component, computed, inject, Service, signal } from '@angular/core';

export interface Ligne {
  readonly reference: string;
  readonly quantite: number;
}

// @Service (Angular 22) equivaut a @Injectable({ providedIn: 'root' }) : une
// seule instance pour toute l'application, donc un etat partage.
@Service()
export class Panier {
  // Le signal ecrivable ne sort pas de la classe.
  readonly #lignes = signal<readonly Ligne[]>([]);

  // L'exterieur recoit un Signal, sans set ni update.
  readonly lignes = this.#lignes.asReadonly();
  readonly nombreArticles = computed(() =>
    this.#lignes().reduce((total, ligne) => total + ligne.quantite, 0),
  );

  // Chaque transition a un nom, et la regle de fusion n'existe qu'ici.
  ajouter(reference: string): void {
    this.#lignes.update((lignes) =>
      lignes.some((l) => l.reference === reference)
        ? lignes.map((l) => (l.reference === reference ? { ...l, quantite: l.quantite + 1 } : l))
        : [...lignes, { reference, quantite: 1 }],
    );
  }

  retirer(reference: string): void {
    this.#lignes.update((lignes) => lignes.filter((l) => l.reference !== reference));
  }
}

@Component({
  selector: 'app-badge-panier',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ panier.nombreArticles() }} article(s)</span>`,
})
export class BadgePanier {
  // Meme instance que celle de la fiche produit : le badge suit sans qu'on
  // le previenne.
  protected readonly panier = inject(Panier);
}

asReadonly() rend un Signal, sans set ni update : écrire panier.lignes.set([]) depuis un composant échoue à la compilation, avec Property 'set' does not exist on type 'Signal<readonly Ligne[]>'. Ce service est déjà un store : une source unique, des lectures dérivées, des écritures nommées. Il suffit à la plupart des applications. Ce qui lui manque, c'est une contrainte qui ne repose pas sur la discipline de chacun, une trace de ce qui a changé et pourquoi, et un moyen de coordonner des changements venus de nombreuses sources.

NgRx Store : actions et reducers

@ngrx/store reprend le modèle de Redux. L'état de l'application est un seul objet immuable, que personne ne modifie directement. Un composant émet une action, un objet qui décrit un événement ; des reducers, fonctions pures qui prennent l'état et l'action et rendent l'état suivant, calculent ce qui change ; des selectors en extraient ce que la vue affiche. Le flux ne va que dans un sens, et chaque changement d'état a une cause nommée.

La documentation de NgRx fixe la règle d'écriture des actions : décrire des événements, pas des commandes. « Article ajouté » depuis la fiche produit, et non « Ajouter l'article » : l'action dit ce qui s'est passé et où, le reducer décide ce que cela change. createActionGroup regroupe les actions d'une même source, forme leur type entre crochets et nomme chaque créateur en camelCase, articleAjoute. Deux événements d'un même groupe qui produiraient le même nom sont une erreur de compilation.

// panier.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';

// Une action decrit un evenement, groupe par l'endroit ou il se produit.
// Types produits : « [Fiche produit] Article ajoute », « [Page panier] Panier vide ».
export const FicheProduitActions = createActionGroup({
  source: 'Fiche produit',
  events: {
    'Article ajoute': props<{ reference: string }>(),
  },
});

export const PagePanierActions = createActionGroup({
  source: 'Page panier',
  events: {
    'Article retire': props<{ reference: string }>(),
    'Panier vide': emptyProps(),
  },
});

Le reducer décide ce que chaque action change, et le piège de ce modèle vient de JavaScript : rien n'empêche un reducer de modifier l'état qu'il reçoit. Celui-ci le fait, avec des types sans readonly pour que la faute compile :

// panier.state.ts, version fautive
import { createReducer, on } from '@ngrx/store';
import { FicheProduitActions } from './panier.actions';

// Memes donnees, sans readonly : sinon la faute ne compilerait pas.
interface Ligne {
  reference: string;
  quantite: number;
}

interface PanierState {
  lignes: Ligne[];
}

export const panierReducer = createReducer<PanierState>(
  { lignes: [] },
  on(FicheProduitActions.articleAjoute, (etat, { reference }) => {
    const ligne = etat.lignes.find((l) => l.reference === reference);
    if (ligne) {
      ligne.quantite++;
    } else {
      etat.lignes.push({ reference, quantite: 1 });
    }
    // Le meme objet qu'a l'entree.
    return etat;
  }),
);

En développement, NgRx gèle chaque état que rend un reducer — c'est la vérification strictStateImmutability, active par défaut — et le premier ajout lève TypeError: Cannot add property 0, object is not extensible, dans le libellé de V8, le moteur de Chrome et de Node.js. En production, ces vérifications sont désactivées : la mutation passe, le reducer rend l'objet reçu, et ni l'état ni ses dérivées ne sont notifiés. Un selector mémoïsé, comme selectNombreArticles à la section suivante, voit la même référence et rend l'ancien total ; la vue ne suit que si autre chose la rafraîchit. Le reducer juste rend un nouvel objet à chaque transition, et ses types readonly font de la mutation une erreur de compilation. createFeature l'associe à un nom et génère ses selectors.

// panier.state.ts
import { createFeature, createReducer, on } from '@ngrx/store';
import { FicheProduitActions, PagePanierActions } from './panier.actions';

export interface Ligne {
  readonly reference: string;
  readonly quantite: number;
}

interface PanierState {
  readonly lignes: readonly Ligne[];
}

const etatInitial: PanierState = { lignes: [] };

// Le reducer est une fonction pure : (etat, action) => nouvel etat. Il ne
// modifie jamais l'etat recu, il en rend un autre.
export const panierFeature = createFeature({
  name: 'panier',
  reducer: createReducer(
    etatInitial,
    on(FicheProduitActions.articleAjoute, (etat, { reference }) => ({
      ...etat,
      lignes: etat.lignes.some((l) => l.reference === reference)
        ? etat.lignes.map((l) =>
            l.reference === reference ? { ...l, quantite: l.quantite + 1 } : l,
          )
        : [...etat.lignes, { reference, quantite: 1 }],
    })),
    on(PagePanierActions.articleRetire, (etat, { reference }) => ({
      ...etat,
      lignes: etat.lignes.filter((l) => l.reference !== reference),
    })),
    on(PagePanierActions.panierVide, (etat) => ({ ...etat, lignes: [] })),
  ),
});

Selectors, et la lecture depuis un composant

Un selector est une fonction pure de l'état. createSelector compose des selectors et mémoïse la projection : tant que ses entrées rendent les mêmes références, elle n'est pas recalculée, exactement comme un computed dont les sources n'ont pas bougé. createFeature a déjà produit un selector par propriété de l'état ; les dérivées s'écrivent par-dessus.

// panier.selectors.ts
import { createSelector } from '@ngrx/store';
import { panierFeature } from './panier.state';

// createFeature a deja genere selectPanierState et selectLignes. Une
// derivee se compose par-dessus, et n'est recalculee que si lignes change.
export const selectNombreArticles = createSelector(panierFeature.selectLignes, (lignes) =>
  lignes.reduce((total, ligne) => total + ligne.quantite, 0),
);

// page-panier.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { PagePanierActions } from './panier.actions';
import { selectNombreArticles } from './panier.selectors';
import { panierFeature } from './panier.state';

@Component({
  selector: 'app-page-panier',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (ligne of lignes(); track ligne.reference) {
      <p>
        {{ ligne.reference }} x {{ ligne.quantite }}
        <button type="button" (click)="retirer(ligne.reference)">Retirer</button>
      </p>
    }
    <p>{{ nombreArticles() }} article(s)</p>
  `,
})
export class PagePanier {
  // Pas de parametre de type : les selectors portent les leurs.
  private readonly store = inject(Store);

  // selectSignal rend un Signal : rien a desabonner, et OnPush suit.
  protected readonly lignes = this.store.selectSignal(panierFeature.selectLignes);
  protected readonly nombreArticles = this.store.selectSignal(selectNombreArticles);

  protected retirer(reference: string): void {
    // Le composant annonce ce qui s'est passe ; il ignore ce que l'etat en fera.
    this.store.dispatch(PagePanierActions.articleRetire({ reference }));
  }
}

selectSignal rend un Signal, que le template lit comme n'importe quel autre : en zoneless et OnPush, c'est la forme naturelle, là où select rend un Observable à passer au pipe async. Le composant ne connaît ni la forme de l'état ni l'effet de son action : il lit des selectors et annonce des événements.

Effects

Un reducer est synchrone et pur : il n'appelle aucune API. Les effets de bord vont dans des effects, des flux RxJS qui reçoivent toutes les actions après leur passage dans les reducers, gardent celles qui les concernent avec ofType, et émettent de nouvelles actions que le Store renvoie aux reducers. Le composant se contente d'annoncer « Page catalogue ouverte » ; l'effect sait qu'il faut charger, et comment. Les exemples de cette section et des suivantes s'appuient sur ce service et ces actions :

// catalogue-api.ts
import { HttpClient } from '@angular/common/http';
import { inject, Service } from '@angular/core';
import { Observable } from 'rxjs';

export interface Produit {
  readonly reference: string;
  readonly libelle: string;
}

@Service()
export class CatalogueApi {
  private readonly http = inject(HttpClient);

  lister(): Observable<Produit[]> {
    return this.http.get<Produit[]>('/api/produits');
  }

  rechercher(terme: string): Observable<Produit[]> {
    return this.http.get<Produit[]>('/api/produits', { params: { q: terme } });
  }
}

// catalogue.actions.ts
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Produit } from './catalogue-api';

export const PageCatalogueActions = createActionGroup({
  source: 'Page catalogue',
  events: { Ouverte: emptyProps() },
});

export const CatalogueApiActions = createActionGroup({
  source: 'API catalogue',
  events: {
    'Chargement reussi': props<{ produits: readonly Produit[] }>(),
    'Chargement echoue': props<{ message: string }>(),
  },
});

Le piège classique est celui que décrit le cours RxJS, la place du catchError, appliqué ici au flux des actions. Posé sur ce flux, il ne rattrape pas seulement l'échec d'un appel : il remplace le flux entier par son observable de repli, qui émet une action puis se termine.

// catalogue.effects.ts
import { HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { CatalogueApi } from './catalogue-api';
import { CatalogueApiActions, PageCatalogueActions } from './catalogue.actions';

export const chargerCatalogue = createEffect(
  (actions$ = inject(Actions), api = inject(CatalogueApi)) =>
    actions$.pipe(
      ofType(PageCatalogueActions.ouverte),
      exhaustMap(() =>
        api.lister().pipe(map((produits) => CatalogueApiActions.chargementReussi({ produits }))),
      ),
      // Au premier echec, le flux des actions est remplace par of(...) : une
      // action d'echec part, puis l'effect se termine. Les ouvertures
      // suivantes ne declenchent plus rien, et aucune erreur ne le signale.
      catchError((erreur: HttpErrorResponse) =>
        of(CatalogueApiActions.chargementEchoue({ message: erreur.message })),
      ),
    ),
  { functional: true },
);

NgRx relance bien un effect qui lève une erreur, jusqu'à dix fois, en la signalant à l'ErrorHandler. Mais un effect qui se termine n'est pas en erreur : il s'arrête sans bruit. Le catchError se place donc sur le flux interne, celui que exhaustMap crée pour chaque ouverture, où un échec ne termine que cet appel-là.

// catalogue.effects.ts
import { HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { CatalogueApi } from './catalogue-api';
import { CatalogueApiActions, PageCatalogueActions } from './catalogue.actions';

export const chargerCatalogue = createEffect(
  (actions$ = inject(Actions), api = inject(CatalogueApi)) =>
    actions$.pipe(
      ofType(PageCatalogueActions.ouverte),
      // exhaustMap ignore une ouverture qui arrive pendant un chargement.
      exhaustMap(() =>
        api.lister().pipe(
          map((produits) => CatalogueApiActions.chargementReussi({ produits })),
          // Sur le flux de l'appel : un echec ne termine que cet appel-la.
          catchError((erreur: HttpErrorResponse) =>
            of(CatalogueApiActions.chargementEchoue({ message: erreur.message })),
          ),
        ),
      ),
    ),
  { functional: true },
);

Avec une API qui échoue au premier appel puis répond, trois ouvertures donnent, dans la version juste, un échec puis deux succès ; dans la version fautive, un échec puis plus rien. exhaustMap convient à un chargement qu'il suffit de faire une fois ; une recherche, où seule compte la dernière saisie, voudrait switchMap. Les effects fonctionnels s'enregistrent avec provideEffects, à côté du Store :

// app.config.ts
import { ApplicationConfig, isDevMode } from '@angular/core';
import { provideEffects } from '@ngrx/effects';
import { provideState, provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import * as catalogueEffects from './catalogue.effects';
import { panierFeature } from './panier.state';

export const appConfig: ApplicationConfig = {
  providers: [
    // La racine reste vide ; chaque feature s'enregistre a part, ici ou dans
    // les providers d'une route chargee a la demande.
    provideStore(),
    provideState(panierFeature),
    provideEffects(catalogueEffects),
    // Redux DevTools : journal des actions et des etats successifs.
    provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
  ],
};

NgRx Signal Store

@ngrx/signals prend le chemin inverse : au lieu d'un état global unique alimenté par des actions, des stores indépendants, chacun un service Angular fabriqué par signalStore à partir de features. withState déclare l'état et en fait un signal par propriété ; withComputed ajoute les dérivées ; withMethods, les opérations. Le panier de la première section devient :

import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';

export interface Ligne {
  readonly reference: string;
  readonly quantite: number;
}

interface PanierState {
  readonly lignes: readonly Ligne[];
}

export const PanierStore = signalStore(
  // Sans providedIn, le store n'est fourni nulle part : il faudrait le
  // placer dans les providers d'un composant ou d'une route.
  { providedIn: 'root' },

  // Chaque propriete de l'etat devient un signal : store.lignes().
  withState<PanierState>({ lignes: [] }),

  withComputed(({ lignes }) => ({
    nombreArticles: computed(() => lignes().reduce((total, l) => total + l.quantite, 0)),
  })),

  // Seul le code du store (methodes, hooks) peut ecrire : l'etat est
  // protege par defaut.
  withMethods((store) => ({
    ajouter(reference: string): void {
      patchState(store, ({ lignes }) => ({
        lignes: lignes.some((l) => l.reference === reference)
          ? lignes.map((l) => (l.reference === reference ? { ...l, quantite: l.quantite + 1 } : l))
          : [...lignes, { reference, quantite: 1 }],
      }));
    },
    retirer(reference: string): void {
      patchState(store, ({ lignes }) => ({
        lignes: lignes.filter((l) => l.reference !== reference),
      }));
    },
  })),
);

@Component({
  selector: 'app-badge-panier',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ store.nombreArticles() }} article(s)</span>`,
})
export class BadgePanier {
  protected readonly store = inject(PanierStore);
}

On retrouve le service à signals, avec la discipline en plus. L'état est protégé par défaut : seul le code du store, méthodes et hooks, reçoit un store écrivable, et patchState(store, …) écrit depuis un composant ne compile pas. L'option protectedState: false lève cette protection, ce que la documentation déconseille. patchState fusionne dans l'état un objet partiel, ou ce que rend une fonction de l'état courant ; comme pour un signal, une mutation en place garde la même référence et ne notifie personne.

Sans providedIn, un store n'est fourni nulle part : on le place dans les providers d'un composant, dont il partage alors la durée de vie, ce qui en fait un état local structuré, ou d'une route, où il survit par défaut à la navigation comme un service. withHooks ajoute onInit et onDestroy, exécutés à la création et à la destruction du store.

rxMethod

withMethods accepte des méthodes asynchrones ordinaires. Pour un flux — une recherche qui attend la fin de la frappe et abandonne la requête précédente —, rxMethod, du point d'entrée @ngrx/signals/rxjs-interop, crée une méthode dont le corps est un pipe RxJS. Elle accepte une valeur, un signal, une fonction qui lit des signals, ou un observable. tapResponse, du paquet @ngrx/operators, traite succès et échec sans laisser l'erreur terminer le flux : c'est le rôle que tenait le catchError interne de la section Effects.

// recherche.store.ts
import { inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { debounceTime, distinctUntilChanged, filter, pipe, switchMap, tap } from 'rxjs';
import { CatalogueApi, Produit } from './catalogue-api';

interface RechercheState {
  readonly resultats: readonly Produit[];
  readonly chargement: boolean;
  readonly erreur: string | null;
}

export const RechercheStore = signalStore(
  // A la racine : les resultats survivent a un aller-retour de navigation.
  { providedIn: 'root' },
  withState<RechercheState>({ resultats: [], chargement: false, erreur: null }),
  withMethods((store, api = inject(CatalogueApi)) => ({
    rechercher: rxMethod<string>(
      pipe(
        debounceTime(300),
        distinctUntilChanged(),
        filter((terme) => terme.length >= 2),
        tap(() => patchState(store, { chargement: true, erreur: null })),
        // switchMap abandonne la requete precedente : une reponse lente ne
        // peut pas ecraser une reponse recente.
        switchMap((terme) =>
          api.rechercher(terme).pipe(
            // tapResponse traite l'echec sans terminer le flux de rxMethod.
            // Pas de finalize ici : il s'executerait aussi quand switchMap
            // abandonne une requete, et remettrait chargement a false alors
            // que la suivante est en cours.
            tapResponse({
              next: (resultats) => patchState(store, { resultats, chargement: false }),
              error: () => patchState(store, { erreur: 'Recherche impossible', chargement: false }),
            }),
          ),
        ),
      ),
    ),
  })),
);

tapResponse accepte aussi un finalize, qui convient sous exhaustMap. Sous switchMap, il s'exécute aussi pour la requête abandonnée, et remettrait chargement à false pendant la suivante : c'est pourquoi next et error l'écrivent eux-mêmes.

Appelée avec un signal, rxMethod le suit au moyen d'un effect : la chaîne reçoit la valeur courante chaque fois que l'effect s'exécute, si bien que deux écritures synchrones successives n'y font passer que la dernière. Le composant n'a pas à relayer chaque frappe : il confie son signal une fois, et son template n'écrit que dans ce signal.

<input #champ type="search" [value]="terme()" (input)="terme.set(champ.value)" />

@if (store.chargement()) {
  <p>Recherche en cours...</p>
} @else if (store.erreur(); as erreur) {
  <p>{{ erreur }}</p>
} @else {
  @for (produit of store.resultats(); track produit.reference) {
    <p>{{ produit.reference }} : {{ produit.libelle }}</p>
  }
}

Reste à savoir à quoi ce suivi se rattache, et cela dépend de l'endroit de l'appel. Appelée hors d'un contexte d'injection, dans ngOnInit par exemple, la méthode ne connaît pas le composant appelant :

import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core';
import { RechercheStore } from './recherche.store';

@Component({
  selector: 'app-recherche',
  templateUrl: './recherche.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Recherche implements OnInit {
  protected readonly store = inject(RechercheStore);
  protected readonly terme = signal('');

  ngOnInit(): void {
    // Hors contexte d'injection, la methode se rattache a l'injecteur ou
    // elle a ete creee, celui de la racine : le suivi de terme survit au
    // composant, et chaque visite en laisse un de plus. En developpement,
    // NgRx 22 previent : « Calling a reactive method outside of an injection
    // context with a signal or observable is deprecated. »
    this.store.rechercher(this.terme);
  }
}

Cette forme est dépréciée depuis NgRx 21.1, en mars 2026, et la documentation annonce qu'elle lèvera une erreur dans une version future. Le suivi doit naître dans le constructeur, ou recevoir l'injecteur du composant en second argument, obtenu par un champ injector = inject(Injector) : this.store.rechercher(this.terme, { injector: this.injector }).

import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { RechercheStore } from './recherche.store';

@Component({
  selector: 'app-recherche',
  templateUrl: './recherche.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Recherche {
  protected readonly store = inject(RechercheStore);
  protected readonly terme = signal('');

  constructor() {
    // Le constructeur est un contexte d'injection : le suivi de terme se
    // rattache au composant et s'arrete avec lui.
    this.store.rechercher(this.terme);
  }
}

Lequel choisir

Service à signalsNgRx Signal StoreNgRx Store et Effects
Dépendanceaucune@ngrx/signals@ngrx/store, @ngrx/effects ; @ngrx/store-devtools en option
Qui écrit l'étatles méthodes du service, si le signal reste privéles méthodes du store, état protégé par défautles reducers, en réponse aux actions
Effets de bordméthodes, promesses ou RxJS à la mainwithMethods, rxMethodeffects
Portéeracine, route ou composantracine, route ou composantun état global, découpé en features
Journal des changementsaucunaucun en standardchaque action et l'état qui en résulte, dans les Redux DevTools
Cérémonieminimalefaibleélevée : actions, reducer, selectors et effects par feature

Le critère n'est pas la taille de l'application mais la forme de son état. La documentation de NgRx propose un repère en cinq lettres, SHARI : un état partagé par de nombreux composants (Shared), persisté puis réhydraté depuis un stockage (Hydrated), disponible quand on revient sur une route (Available), obtenu par un effet de bord (Retrieved), modifié par des actions venues d'autres sources (Impacted). Elle prévient aussi que le Store n'est « not meant to be the shortest or quickest way to write code ».

  • Le service à signals suffit tant que l'état a un propriétaire évident et qu'une poignée de méthodes en décrivent les transitions. C'est le point de départ, et il ne coûte aucune dépendance.
  • Le Signal Store paie quand plusieurs services répètent la même forme — données, chargement, erreur — ou quand la protection de l'état ne doit plus dépendre d'une convention : il impose ce que le service ne faisait que permettre. Le passage de l'un à l'autre est presque mécanique, comme le montrent les deux versions du panier.
  • Le Store et ses effects se justifient quand de nombreuses sources font évoluer le même état et qu'il faut pouvoir répondre à « pourquoi l'état est-il ainsi ? » : chaque changement y est une action nommée, visible avec l'état qui en résulte. Le prix est celui du tableau : quatre sortes de fichiers par feature, et RxJS dans les effects.

Entre les deux, le plugin Events de @ngrx/signals, apparu avec NgRx 19.2, apporte au Signal Store des événements, des reducers et des gestionnaires d'événements dans le style Flux, pour coordonner plusieurs stores sans qu'ils s'appellent directement. Le Signal Store n'a en revanche aucun lien officiel avec les Redux DevTools : sa FAQ renvoie à une feature tierce, withDevtools de @angular-architects/ngrx-toolkit.

Ce cours vous a servi ? Offrir un café Signaler une erreur