Quand les Signals Angular sont devenus stables dans la v17, la réaction immédiate de la communauté a été de les présenter comme un remplacement de NgRx. Je comprends pourquoi. Les Signals sont intégrés, plus simples, et couvrent beaucoup de terrain. Mais j'ai fait tourner NgRx sur une plateforme de cartes de crédit avec plus de 50 000 utilisateurs, et je n'arracherais pas un store comme celui-là juste parce que les signals existent. Les deux outils résolvent des problèmes différents.
Ce pour quoi les Signals excellent
Les Signals sont une primitive réactive synchrone. Un signal détient une valeur. Tout ce qui lit cette valeur dans un contexte réactif, un template, un computed() ou un effect(), suit la dépendance automatiquement et se met à jour quand le signal change. Pas de pipes, pas de souscriptions, pas d'asynchrone.
import { signal, computed } from '@angular/core';
import { Account } from './accounts.model';
const selectedAccountId = signal<string | null>(null);
const accounts = signal<Account[]>([]);
const selectedAccount = computed(() =>
accounts().find(a => a.id === selectedAccountId())
);
// In template: {{ selectedAccount()?.balance | currency }}
// Updates instantly when either signal changesPour l'état limité à une fonctionnalité, les signals sont dramatiquement moins cérémonieux que NgRx. Un service basé sur les signals pour une fonctionnalité remplace un reducer, un ensemble de sélecteurs et le boilerplate associé par une trentaine de lignes de TypeScript. En voici un à titre d'exemple pour une fonctionnalité de demande de prêt.
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { LendingApiService, LoanApplication } from './lending-api.service';
@Injectable({ providedIn: 'root' })
export class LendingStateService {
private api = inject(LendingApiService);
private destroyRef = inject(DestroyRef);
private _applications = signal<LoanApplication[]>([]);
private _loading = signal(false);
private _error = signal<string | null>(null);
readonly applications = this._applications.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly pendingApplications = computed(() =>
this._applications().filter(a => a.status === 'pending')
);
loadApplications(): void {
this._loading.set(true);
this.api.getApplications()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: apps => {
this._applications.set(apps);
this._loading.set(false);
},
error: err => {
this._error.set(err.message);
this._loading.set(false);
}
});
}
}loadApplications s'exécute en dehors d'un contexte d'injection, donc takeUntilDestroyed a besoin qu'on lui passe explicitement le DestroyRef. Sans ça, la subscription survit au service et écrit dans des signals que plus personne ne lit.
Quand les Signals commencent à montrer leurs limites
Les Signals n'ont pas de journal d'actions. Vous ne pouvez pas rejouer les transitions d'état. Vous ne pouvez pas ouvrir Redux DevTools et voir ce qui s'est passé entre le clic de l'utilisateur sur 'Soumettre' et l'apparition de l'état d'erreur. Dans un environnement réglementé comme une banque, cette traçabilité compte pour le débogage des incidents de production.
Les Signals n'ont pas non plus de pattern intégré pour les effets de bord asynchrones complexes. Charger des données à l'initialisation d'une route, réessayer en cas d'échec, afficher une mise à jour optimiste pendant que la vraie réponse est en vol, puis annuler en cas d'erreur : c'est là que les NgRx Effects avec leur cycle standardisé action-effect-reducer justifient leur coût de complexité.
Ce pour quoi NgRx excelle réellement
NgRx n'est pas seulement un conteneur d'état. C'est un journal d'audit. Chaque changement d'état est une action dispatchée avec un nom et un payload. Quand une demande est soumise et que l'utilisateur se retrouve dans un état d'erreur, vous pouvez ouvrir Redux DevTools et rejouer exactement ce qui s'est passé : LoadApplications dispatché, LoadApplicationsSuccess reçu, SubmitApplication dispatché, SubmitApplicationFailure reçu avec l'erreur serveur. C'est par cette capacité de replay que le store se rentabilise en temps de débogage.
// lending.actions.ts
import { createAction, props } from '@ngrx/store';
import { Confirmation } from './lending-api.service';
export const submitApplication = createAction(
'[Lending] Submit Application',
props<{ applicationId: string }>()
);
export const submitApplicationSuccess = createAction(
'[Lending] Submit Application Success',
props<{ confirmation: Confirmation }>()
);
export const submitApplicationFailure = createAction(
'[Lending] Submit Application Failure',
props<{ error: string }>()
);
// lending.effects.ts
import { Injectable, inject } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { catchError, exhaustMap, map, of } from 'rxjs';
import { LendingApiService } from './lending-api.service';
@Injectable()
export class LendingEffects {
private actions$ = inject(Actions);
private lendingApi = inject(LendingApiService);
submitApplication$ = createEffect(() =>
this.actions$.pipe(
ofType(submitApplication),
exhaustMap(({ applicationId }) =>
this.lendingApi.submit(applicationId).pipe(
map(confirmation => submitApplicationSuccess({ confirmation })),
catchError(err => of(submitApplicationFailure({ error: err.message })))
)
)
)
);
}Comment je découperais ça dans une grosse application
L'état du shell applicatif a sa place dans le store NgRx : authentification, permissions, notifications globales, données inter-fonctionnalités. Dans une grosse application découpée en fonctionnalités chargées en lazy ou en micro-frontends, ces éléments sont partagés entre les frontières et doivent survivre aux changements de route. La piste d'audit compte ici.
L'état de fonctionnalité a sa place dans des services basés sur les signals, sur le modèle du LendingStateService ci-dessus : des signals pour les données locales, une lecture depuis NgRx pour le contexte de session de l'utilisateur, et aucune action NgRx pour l'état UI purement local comme quelle étape est active ou si un panneau est développé.
Faire le pont entre NgRx et les Signals avec toSignal
toSignal de @angular/core/rxjs-interop convertit n'importe quel observable en signal. C'est le pont entre le store NgRx et les templates basés sur les signals.
import { toSignal } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectUserPermissions } from './auth.selectors';
@Component({
standalone: true,
template: `
@if (permissions()?.canApply) {
<button (click)="apply()">Apply Now</button>
}
`
})
export class LendingComponent {
private store = inject(Store);
// NgRx selector consumed as a signal. No async pipe, no subscription.
permissions = toSignal(this.store.select(selectUserPermissions));
}NgRx SignalStore : le meilleur des deux mondes
NgRx SignalStore utilise les signals en interne mais ajoute l'immuabilité style reducer et le pattern rxMethod pour gérer l'asynchrone. Si vous voulez l'ergonomie des signals avec l'approche structurée de NgRx, ça vaut la peine d'être évalué. Sur une plateforme établie qui fait tourner le store NgRx classique, une migration de store est une décision d'architecture, pas celle d'un développeur de fonctionnalités. Pour de nouveaux projets, je commencerais par celui-ci au lieu de choisir entre les deux. Je détaille un SignalStore avec withState, withComputed et un rxMethod pour l'asynchrone dans mon article sur NgRx en pratique, donc je ne répète pas le code ici.
- Nouveau projet, petite équipe, état limité à la fonctionnalité : commencez avec des services basés sur les signals
- Nouveau projet, industrie réglementée, grande équipe : NgRx ou NgRx SignalStore
- Projet NgRx existant : utilisez toSignal pour ponter les sélecteurs vers les templates, ajoutez des services signal pour les nouvelles fonctionnalités
- Ce qui ne devrait jamais vivre dans les signals : l'état que plusieurs fonctionnalités chargées en lazy lisent et écrivent simultanément
Les Signals réduisent la cérémonie. NgRx impose la discipline. La meilleure configuration utilise les deux : signals pour l'état de fonctionnalité, NgRx pour les préoccupations à l'échelle de l'application.
Références
- Angular Signals overview. https://angular.dev/guide/signals
- toSignal API reference. https://angular.dev/api/core/rxjs-interop/toSignal
- NgRx official documentation. https://ngrx.io/docs
- NgRx SignalStore guide. https://ngrx.io/guide/signals/signal-store