Retour au Blog
Angular Development23 février 202611 min de lecture

NgRx en pratique : quand l'utiliser et quand s'en passer

DR

Doug Ribeiro

11 min de lecture

NgRx peut bloquer la vélocité de sprint d'une petite équipe pendant des mois. C'est un patron d'échec, pas le post-mortem d'une équipe en particulier, et il a toujours la même forme : quelques développeurs, des délais serrés, et quelqu'un qui propose de mettre chaque morceau d'état dans NgRx pendant que tout le monde acquiesce. Les premiers sprints partent en écriture d'actions, de reducers, de selectors et d'effects pour de l'état qui aurait pu vivre dans deux services. Ensuite, ajouter un champ à une tranche veut dire modifier l'action, l'interface d'état, le reducer, le selector et le composant qui le lit. Finalement, quelqu'un sort les meta-reducers pour gérer la complexité introduite pour gérer la complexité. La réponse est rarement d'arracher NgRx. Il mérite sa place pour l'état qui traverse vraiment les fonctionnalités. Ce qui doit changer, c'est le défaut qui consiste à tout mettre dans le store.

Ce que NgRx résout réellement

L'argument honnête pour NgRx n'est pas l'expérience développeur. C'est l'auditabilité et la coordination d'état inter-fonctionnalités. Quand vous avez un état lu et muté par des composants dans différents feature modules en lazy loading, quand vous avez besoin du time-travel Redux DevTools pour déboguer des problèmes d'état en production, quand plusieurs équipes ont besoin de contrats explicites sur comment les transitions d'état se produisent, c'est là que NgRx mérite sa cérémonie.

Chez BMO, ces conditions étaient réelles. J'ai travaillé sur la plateforme numérique de demande de prêt de la banque, qui est construite en micro-frontends. NgRx était déjà en place quand j'ai rejoint l'équipe, et les décisions d'architecture appartiennent à une équipe séparée. Après avoir travaillé dedans, je pense que c'est le bon choix pour ce genre de produit : dans une grosse application financière, un service avec un BehaviorSubject deviendrait impossible à déboguer en six mois. NgRx rend les transitions explicites et traçables.

Le pattern classique, sans embellissement

Avant d'aller vers Signal Store ou toute alternative plus légère, comprenez ce contre quoi vous décidez. Le pattern classique Actions, Reducers, Selectors, Effects est verbeux par design. La verbosité est la documentation. Voici une tranche illustrative pour une soumission de demande de prêt générique, écrite pour cet article et non tirée d'une vraie base de code :

typescript
// actions/application.actions.ts
export const submitApplication = createAction('[Application] Submit');
export const submitApplicationSuccess = createAction(
  '[Application] Submit Success',
  props<{ applicationId: string; offerAmount: number }>()
);
export const submitApplicationFailure = createAction(
  '[Application] Submit Failure',
  props<{ error: string; retryable: boolean }>()
);

// reducers/application.reducer.ts
export interface ApplicationState {
  applicationId: string | null;
  offerAmount: number | null;
  submitting: boolean;
  error: string | null;
  retryable: boolean;
}

const initialState: ApplicationState = {
  applicationId: null,
  offerAmount: null,
  submitting: false,
  error: null,
  retryable: false
};

export const applicationReducer = createReducer(
  initialState,
  on(submitApplication, state => ({ ...state, submitting: true, error: null })),
  on(submitApplicationSuccess, (state, { applicationId, offerAmount }) => ({
    ...state, applicationId, offerAmount, submitting: false
  })),
  on(submitApplicationFailure, (state, { error, retryable }) => ({
    ...state, error, retryable, submitting: false
  }))
);
typescript
// effects/application.effects.ts
@Injectable()
export class ApplicationEffects {
  private actions$ = inject(Actions);
  private applicationService = inject(ApplicationService);

  submit$ = createEffect(() =>
    this.actions$.pipe(
      ofType(submitApplication),
      exhaustMap(() =>
        this.applicationService.submit().pipe(
          map(result => submitApplicationSuccess(result)),
          catchError(err => of(submitApplicationFailure({
            error: err.message,
            retryable: err.status >= 500
          })))
        )
      )
    )
  );
}

J'utilise exhaustMap ici, pas switchMap. Sur une soumission de demande de prêt, on n'annule pas les requêtes en cours quand l'utilisateur double-clique. C'est le genre de décision qui devient explicite avec les Effects et invisible avec une méthode de service.

Quand sauter le pattern classique

  • État local à une fonctionnalité et jamais lu en dehors
  • Chargement de données async simple sans dépendances inter-fonctionnalités
  • Projets solo ou petite équipe où la piste d'audit Redux DevTools n'apporte rien
  • Phases de prototype et MVP où on ne peut pas se permettre le coût du boilerplate
  • État qui change en réponse aux entrées utilisateur dans un seul formulaire ou étape de wizard

Pour tout ça, un service avec des signals est le bon choix aujourd'hui. Sur les versions d'Angular antérieures à la 16, où les signals ne sont pas une option, la même manœuvre fonctionne avec des services simples et des BehaviorSubjects : sortez du store l'état de type wizard et gardez NgRx uniquement pour l'état qui traverse réellement les fonctionnalités. Des centaines de lignes d'actions, reducers et selectors peuvent s'effondrer en deux petits services, plus simples à tester, plus simples à déboguer et plus rapides à changer.

NgRx Signal Store : le juste milieu

NgRx Signal Store est sorti en developer preview dans NgRx v17 et est devenu prêt pour la production dans NgRx v18. Notez que ce sont des versions de NgRx, pas d'Angular. C'est vraiment bien. Il vous donne la gestion d'état structurée et explicite de NgRx avec des signals au lieu d'observables RxJS, et sans la cérémonie Actions, Reducers, Effects pour les cas qui n'en ont pas besoin.

typescript
import { computed, inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { signalStore, withState, withMethods, withComputed, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap } from 'rxjs';
import { DocumentService, UploadedDocument } from './document.service';

interface DocumentUploadState {
  applicationId: string | null;
  documents: UploadedDocument[];
  uploading: boolean;
  error: string | null;
}

export const DocumentUploadStore = signalStore(
  { providedIn: 'root' },
  withState<DocumentUploadState>({
    applicationId: null,
    documents: [],
    uploading: false,
    error: null
  }),
  withComputed(({ documents }) => ({
    documentCount: computed(() => documents().length),
    hasRequiredDocuments: computed(() => documents().length >= 2)
  })),
  withMethods((store, service = inject(DocumentService)) => ({
    uploadDocument: rxMethod<File>(pipe(
      tap(() => patchState(store, { uploading: true })),
      switchMap(file =>
        service.upload(file).pipe(
          tapResponse(
            doc => patchState(store, { documents: [...store.documents(), doc], uploading: false }),
            err => patchState(store, { error: err.message, uploading: false })
          )
        )
      )
    ))
  }))
);

tapResponse a quitté @ngrx/component-store pour @ngrx/operators dans NgRx 18, et c'est l'import qu'il vous faut sur toute version actuelle. J'utilise Signal Store pour l'état au niveau fonctionnalité qui est trop complexe pour un service brut mais qui n'a pas besoin de l'audit Redux DevTools. Le seuil est approximativement : plusieurs opérations async avec des états de chargement interdépendants, ou de l'état partagé entre trois composants ou plus au sein de la même fonctionnalité.

La décision que j'utilise vraiment

  • État local au composant : signals directement dans le composant
  • État au niveau fonctionnalité avec complexité modérée : NgRx Signal Store
  • État global avec lectures inter-fonctionnalités, orchestration async complexe, ou exigences d'audit : NgRx classique
  • Si vous avez hérité du NgRx classique : gardez-le, ne le réécrivez pas, les patterns sont solides
Utiliser NgRx quandÉtat partagé entre 3 fonctionna…Besoin de Redux DevTools pour d…Effets de bord async complexes …Grande équipe avec des contrats…Se passer de NgRx quandÉtat local à une seule fonction…Réactivité simple au niveau du …Application petite ou moyenne a…L'équipe préfère moins de boile…VS

NgRx ne rend pas votre application plus professionnelle. Il rend les transitions d'état explicites. Ça n'a de valeur que si vous en perdiez la trace.

L'erreur que je vois le plus souvent est des équipes qui adoptent NgRx parce que ça signale du sérieux, pas parce que ça résout un problème qu'elles ont. Si vous n'avez jamais livré un bogue causé par un état partagé imprévisible entre fonctionnalités, vous n'avez probablement pas encore besoin du NgRx classique. Construisez avec des signals et Signal Store. Ajoutez la machinerie Redux complète quand la complexité le demande réellement.

Références

AngularNgRxState ManagementTypeScript