Gestione dello stato in Angular con NgRx: Store, effetti e SignalStore
In DocumentaleInventato, un gestionale documentale, c’è una pagina con i documenti da firmare. In testata, su ogni pagina, un badge dice quanti ne restano. Sono due componenti diversi che mostrano gli stessi dati: quando si firma un documento dalla lista, anche il badge deve scendere.
La prima soluzione è un servizio condiviso, con un signal o un BehaviorSubject, che
i due componenti leggono. Funziona finché lo stato è piccolo. Poi arrivano il
caricamento, gli errori, il bottone da disabilitare durante la firma, e ogni
componente aggiorna lo stato a modo suo. Quando il badge mostra un numero sbagliato,
non si sa chi l’ha cambiato.
NgRx risponde con una regola: lo stato cambia in un posto solo, e solo descrivendo cosa è successo.
Questo articolo ha tre parti: prima NgRx Store, il modello classico con azioni, reducer, selettori ed effetti; poi SignalStore, nato per i signal di Angular, che fa lo stesso lavoro con meno codice; infine come scegliere.
Il flusso
| Pezzo | Cosa fa | In NgRx |
|---|---|---|
| Azione | Descrive un evento: “la pagina è stata aperta”, “il server ha risposto”. | createActionGroup |
| Reducer | Riceve lo stato attuale e un’azione, restituisce il nuovo stato. | createReducer, on |
| Selettore | Legge una parte dello stato, o ne ricava un valore. | createSelector, createFeature |
| Effetto | Ascolta le azioni, fa il lavoro che esce dall’app (HTTP) e risponde con altre azioni. | createEffect |
| Store | Contiene lo stato e riceve le azioni. | Store, provideStore |
Il giro è sempre lo stesso. Il componente invia un’azione. Il reducer calcola il nuovo stato. I selettori lo portano ai componenti. Se serve una chiamata HTTP, la fa un effetto, e il risultato torna come un’altra azione, che ripassa dal reducer.
Ogni azione passa prima dai reducer, poi dagli effetti: quando un effetto la riceve, lo stato è già aggiornato.
@ngrx/storeL’installazione
npm install @ngrx/store @ngrx/effects @ngrx/operators @ngrx/store-devtools
NgRx segue le versioni major di Angular: NgRx 22 vuole Angular 22.
1. Le azioni
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Documento } from './documento';
export const DocumentiPageActions = createActionGroup({
source: 'Documenti Page', // diventa [Documenti Page] nel tipo
events: {
// il tipo sarà '[Documenti Page] Aperta'
Aperta: emptyProps(),
// il tipo sarà '[Documenti Page] Firma Richiesta'
'Firma Richiesta': props<{ id: string }>(),
},
});
export const DocumentiApiActions = createActionGroup({
source: 'Documenti API',
events: {
Caricati: props<{ documenti: Documento[] }>(),
'Caricamento Fallito': props<{ errore: string }>(),
Firmato: props<{ documento: Documento }>(),
'Firma Fallita': props<{ id: string; errore: string }>(),
},
});
Il tipo è la stringa che identifica un’azione. NgRx lo compone da solo: source
tra parentesi quadre, poi il nome dell’evento. Per ogni evento, createActionGroup
genera anche un creatore, una funzione con lo stesso nome in camelCase che
costruisce l’azione:
DocumentiPageActions.firmaRichiesta({ id: '42' });
// { type: '[Documenti Page] Firma Richiesta', id: '42' }
L’azione è un oggetto semplice: il tipo, più i dati dichiarati con props. Il tipo è
quello che si legge nei DevTools, quindi conviene che dica da dove arriva l’azione.
'Firma Richiesta' è tra apici e Aperta no per una regola di JavaScript: una chiave
di un oggetto che contiene uno spazio va scritta come stringa. Lo spazio serve al tipo,
che si legge meglio; il creatore lo perde e diventa firmaRichiesta.
Due gruppi, divisi per origine: la pagina dice cosa ha fatto l’utente, l’API cosa ha risposto il server.
E sono eventi, non comandi. La pagina dice Aperta, non CaricaDocumenti: non sa
che dopo partirà una GET. Domani la stessa azione potrà azzerare un filtro o
registrare una statistica, e il componente non cambierà.
2. Lo stato e il reducer
interface DocumentiState {
documenti: Documento[];
caricamento: boolean;
inFirma: string[];
errore: string | null;
}
const initialState: DocumentiState = {
documenti: [],
caricamento: false,
inFirma: [],
errore: null,
};
export const documentiFeature = createFeature({
name: 'documenti',
reducer: createReducer(
initialState,
on(DocumentiPageActions.aperta, (state) => ({
...state,
caricamento: true,
errore: null,
})),
on(DocumentiApiActions.caricati, (state, { documenti }) => ({
...state,
documenti,
caricamento: false,
})),
on(DocumentiApiActions.caricamentoFallito, (state, { errore }) => ({
...state,
caricamento: false,
errore,
})),
on(DocumentiPageActions.firmaRichiesta, (state, { id }) => ({
...state,
inFirma: [...state.inFirma, id],
})),
on(DocumentiApiActions.firmato, (state, { documento }) => ({
...state,
documenti: state.documenti.map((d) =>
d.id === documento.id ? documento : d,
),
inFirma: state.inFirma.filter((id) => id !== documento.id),
})),
on(DocumentiApiActions.firmaFallita, (state, { id, errore }) => ({
...state,
inFirma: state.inFirma.filter((i) => i !== id),
errore,
})),
),
});
Il reducer è una funzione pura: stesso stato e stessa azione, stesso risultato. Niente chiamate HTTP, niente date, niente valori casuali. Per questo si prova senza Angular, chiamandolo come una funzione qualunque.
createFeature mette insieme il nome della parte di stato, documenti, il reducer e
i selettori, che vediamo tra poco. Ha un vincolo: niente proprietà facoltative nello
stato. Per questo errore è string | null e non errore?: string.
Ogni volta un oggetto nuovo
Ogni on restituisce un oggetto nuovo: ...state copia il resto, map e
filter creano array nuovi. Mai push, mai un’assegnazione su state.
Nota: i tre puntini.
...è l’operatore spread di JavaScript: copia le proprietà di un oggetto, o gli elementi di un array, dentro uno nuovo. Quello che si scrive dopo sostituisce quello che è stato copiato.const prima = { caricamento: false, errore: 'Timeout' }; const dopo = { ...prima, caricamento: true }; // dopo: { caricamento: true, errore: 'Timeout' } // prima non cambia, e dopo è un altro oggetto: dopo !== prima const ids = ['1', '2']; const conIlTerzo = [...ids, '3']; // conIlTerzo: ['1', '2', '3'], ids non cambiaLa copia è superficiale: un array dentro l’oggetto non viene copiato, il nuovo oggetto punta allo stesso. Per questo nel reducer anche
inFirmaedocumentisi ricreano, con[...state.inFirma, id],mapefilter.
Il motivo è che NgRx e Angular capiscono che qualcosa è cambiato confrontando i
riferimenti: stesso oggetto, niente di nuovo. E da Angular 22 i componenti sono
OnPush per default, quindi si aggiornano proprio così.
Se un reducer modifica lo stato invece di ricrearlo, in sviluppo se ne accorge subito
uno dei runtime check di NgRx, strictStateImmutability, attivo per default: lo
stato è congelato, e il push lancia
TypeError: Cannot add property 0, object is not extensible.
Nella build di produzione i runtime check sono spenti. Lì la modifica passa, l’array resta lo stesso, e chi lo confronta per riferimento non vede niente: nessun errore, e la pagina non si aggiorna. L’errore in sviluppo è un favore.
Feature Creators Runtime checks3. I selettori
createFeature genera da solo un selettore per ogni proprietà dello stato:
selectDocumenti, selectCaricamento, selectInFirma, selectErrore, più
selectDocumentiState per tutto l’oggetto. I valori ricavati si aggiungono con
extraSelectors:
extraSelectors: ({ selectDocumenti }) => {
const selectDaFirmare = createSelector(selectDocumenti, (documenti) =>
documenti.filter((d) => d.stato === 'da-firmare'),
);
const selectNumeroDaFirmare = createSelector(
selectDaFirmare,
(d) => d.length,
);
return { selectDaFirmare, selectNumeroDaFirmare };
},
I selettori creati con createSelector hanno la memoization: ricordano l’ultimo
input. Se cambia solo caricamento, l’array documenti è lo stesso di prima, e
selectDaFirmare restituisce lo stesso risultato senza rifare il filtro. Anche questo
si basa sui riferimenti: un altro motivo per non modificare mai lo stato.
4. Gli effetti
Il caricamento dei documenti:
export const caricaDocumenti = createEffect(
(actions$ = inject(Actions), api = inject(DocumentiApi)) => {
return actions$.pipe(
ofType(DocumentiPageActions.aperta),
exhaustMap(() =>
api.elenco().pipe(
map((documenti) => DocumentiApiActions.caricati({ documenti })),
catchError((e: HttpErrorResponse) =>
of(DocumentiApiActions.caricamentoFallito({ errore: e.message })),
),
),
),
);
},
{ functional: true },
);
Un effetto è un Observable che parte da Actions, il flusso di tutte le azioni, e
restituisce altre azioni, che NgRx invia allo store.
{ functional: true }: l’effetto è una funzione, non serve una classe. Le dipendenze arrivano come parametri coninject()come valore di default, così in un test si possono passare quelle finte.ofTypelascia passare soloaperta.exhaustMap: mentre un caricamento è in corso, le altreapertavengono ignorate. Due aperture di fila fanno una sola richiesta.catchErrorsta dentroexhaustMap, sulla chiamata HTTP. Non è un dettaglio.
L’errore che non dà nessun avviso
Basta spostare catchError di un livello:
return actions$.pipe(
ofType(DocumentiPageActions.aperta),
exhaustMap(() => api.elenco()),
map((documenti) => DocumentiApiActions.caricati({ documenti })),
catchError((e: HttpErrorResponse) =>
of(DocumentiApiActions.caricamentoFallito({ errore: e.message })),
),
);
Il primo errore sembra gestito bene: arriva caricamentoFallito e la pagina mostra il
messaggio. Ma catchError sostituisce tutto il flusso con of(...), che invia
un’azione e finisce. L’effetto è terminato.
Alla prossima apertura il reducer mette caricamento a true, ma la richiesta non
parte più: la pagina resta su “Caricamento…” per sempre, e in console non c’è niente.
E senza nessun catchError? NgRx passa l’errore all’ErrorHandler di Angular e
rimette in ascolto l’effetto, ma solo dieci volte: all’undicesimo errore l’effetto
si ferma, anche questa volta senza dirlo. E in ogni caso l’azione di errore non
arriva, quindi il reducer non sa che il caricamento è finito.
La regola: gli errori si gestiscono dentro, sull’Observable della singola richiesta.
La firma, in parallelo
export const firmaDocumento = createEffect(
(actions$ = inject(Actions), api = inject(DocumentiApi)) => {
return actions$.pipe(
ofType(DocumentiPageActions.firmaRichiesta),
mergeMap(({ id }) =>
api.firma(id).pipe(
mapResponse({
next: (documento) => DocumentiApiActions.firmato({ documento }),
error: (e: HttpErrorResponse) =>
DocumentiApiActions.firmaFallita({ id, errore: e.message }),
}),
),
),
);
},
{ functional: true },
);
mapResponse, di @ngrx/operators, è map e catchError insieme, e il caso di
errore è obbligatorio: è difficile dimenticarlo.
Qui c’è mergeMap, non exhaustMap: le firme di documenti diversi sono
indipendenti, e l’utente può firmarne due di fila. La scelta dell’operatore decide
cosa succede quando arriva un’azione mentre una richiesta è ancora in corso:
| Operatore | La nuova richiesta… | Va bene per |
|---|---|---|
switchMap |
annulla quella in corso | ricerche: conta solo l’ultima |
mergeMap |
parte in parallelo | operazioni indipendenti, come le firme |
concatMap |
aspetta il suo turno | operazioni dove l’ordine conta |
exhaustMap |
viene ignorata | caricamenti, invii di un form |
5. La registrazione
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(),
provideStore(),
provideState(documentiFeature),
provideEffects(documentiEffects),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
],
};
provideStore() crea lo store, provideState registra la parte documenti.
provideEffects riceve gli effetti, qui tutto il modulo:
import * as documentiEffects from './documenti/documenti.effects'.
provideState e provideEffects si possono mettere anche nei providers di una
rotta: lo stato e gli effetti di quella parte dell’app si registrano quando la rotta
viene caricata.
provideStoreDevtools collega lo store all’estensione Redux DevTools del browser: si
vede ogni azione, lo stato dopo ognuna, e si può tornare indietro nel tempo.
maxAge tiene gli ultimi 25 stati, logOnly in produzione lascia solo la lettura.
6. I componenti
@Component({
selector: 'app-documenti-da-firmare',
template: `
@if (caricamento()) {
<p>Caricamento…</p>
}
@if (errore(); as errore) {
<p role="alert">{{ errore }}</p>
}
<ul>
@for (documento of daFirmare(); track documento.id) {
<li>
{{ documento.titolo }}
<button
(click)="firma(documento.id)"
[disabled]="inFirma().includes(documento.id)"
>
Firma
</button>
</li>
} @empty {
<li>Nessun documento da firmare.</li>
}
</ul>
`,
})
export class DocumentiDaFirmare implements OnInit {
private readonly store = inject(Store);
protected readonly daFirmare = this.store.selectSignal(
documentiFeature.selectDaFirmare,
);
protected readonly caricamento = this.store.selectSignal(
documentiFeature.selectCaricamento,
);
protected readonly inFirma = this.store.selectSignal(
documentiFeature.selectInFirma,
);
protected readonly errore = this.store.selectSignal(
documentiFeature.selectErrore,
);
ngOnInit() {
this.store.dispatch(DocumentiPageActions.aperta());
}
firma(id: string) {
this.store.dispatch(DocumentiPageActions.firmaRichiesta({ id }));
}
}
selectSignal trasforma un selettore in un signal: nel template si legge con
daFirmare(), senza subscribe e senza pipe async. Il componente non ha HTTP né
logica: legge signal e invia azioni.
Il badge in testata:
@Component({
selector: 'app-badge-firme',
template: `
@if (numero() > 0) {
<span class="badge">{{ numero() }} da firmare</span>
}
`,
})
export class BadgeFirme {
protected readonly numero = inject(Store).selectSignal(
documentiFeature.selectNumeroDaFirmare,
);
}
Il badge non sa niente della lista. Quando arriva firmato, il reducer aggiorna
documenti, e i due componenti si aggiornano insieme, perché leggono lo stesso
stato.
Provare i pezzi
Reducer e selettori sono funzioni pure, e si provano come tali:
const dopo = reducer(
iniziale,
DocumentiApiActions.caricati({ documenti: [contratto] }),
);
expect(dopo.documenti).toEqual([contratto]);
Per un componente, provideMockStore di @ngrx/store/testing sostituisce lo store
con uno finto: con overrideSelector si decide cosa restituisce ogni selettore,
anche quelli letti con selectSignal. Per un effetto, provideMockActions di
@ngrx/effects/testing gli fornisce le azioni, e si controllano quelle che
restituisce.
SignalStore: la stessa funzione, in un file
NgRx Store è nato quando in Angular la reattività passava da RxJS: lo store espone lo
stato come Observable, e selectSignal è un ponte aggiunto dopo, quando sono arrivati
i signal. Con i signal Angular ha un suo modo di propagare i cambiamenti, e NgRx ha
una seconda libreria costruita direttamente su quello: @ngrx/signals.
La documentazione la presenta come una libreria a sé, con una soluzione per gestire
lo stato in modo reattivo e una serie di strumenti per i signal di Angular. A sé vuol
dire che non dipende dallo Store: si installa da sola, senza @ngrx/store. E il
nucleo non usa RxJS: lo usano solo rxMethod e il plugin Events, che vediamo tra poco.
npm install @ngrx/signals
@ngrx/signals
Ecco lo stesso stato, con le stesse operazioni:
export const DocumentiStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ documenti }) => {
const daFirmare = computed(() =>
documenti().filter((d) => d.stato === 'da-firmare'),
);
return { daFirmare, numeroDaFirmare: () => daFirmare().length };
}),
withMethods((store, api = inject(DocumentiApi)) => ({
carica: rxMethod<void>(
pipe(
tap(() => patchState(store, { caricamento: true, errore: null })),
exhaustMap(() =>
api.elenco().pipe(
tapResponse({
next: (documenti) => patchState(store, { documenti }),
error: (e: HttpErrorResponse) =>
patchState(store, { errore: e.message }),
finalize: () => patchState(store, { caricamento: false }),
}),
),
),
),
),
firma: rxMethod<string>(
pipe(
tap((id) =>
patchState(store, (s) => ({ inFirma: [...s.inFirma, id] })),
),
mergeMap((id) =>
api.firma(id).pipe(
tapResponse({
next: (documento) =>
patchState(store, (s) => ({
documenti: s.documenti.map((d) =>
d.id === id ? documento : d,
),
})),
error: (e: HttpErrorResponse) =>
patchState(store, { errore: e.message }),
finalize: () =>
patchState(store, (s) => ({
inFirma: s.inFirma.filter((i) => i !== id),
})),
}),
),
),
),
),
})),
withHooks({
onInit: (store) => store.carica(),
}),
);
initialState e il suo tipo sono gli stessi dello Store. Lo store si compone di
pezzi, uno dopo l’altro:
withStatecrea un signal per ogni proprietà:store.documenti(),store.caricamento().withComputedaggiunge i valori ricavati, il posto dei selettori. Accetta uncomputedo una funzione semplice, che diventa uncomputedda sola.withMethodsaggiunge i metodi. Lo stato si cambia conpatchState, che crea un nuovo stato unendo le proprietà passate a quelle esistenti. Gli array si ricreano come nel reducer:map,filter, maipush.rxMethodcrea un metodo che è una pipeline RxJS: gli stessiexhaustMapemergeMapdegli effetti, con gli stessi ragionamenti. Anche qui gli errori si gestiscono dentro, contapResponse.withHookseseguecarica()quando lo store viene creato.
Con providedIn: 'root' c’è un’istanza sola per tutta l’app: il badge inietta lo
stesso store e legge numeroDaFirmare(). Senza, lo store si mette nei providers di
un componente: ogni componente ha la sua istanza, che sparisce con lui. È la scelta
per lo stato locale.
Lo stato è protetto: da fuori si legge, ma patchState(store, …) scritto in un
componente non compila. Si cambia solo con i metodi dello store. È un controllo di
TypeScript: in fase di esecuzione non c’è un blocco.
Il componente diventa così:
@Component({
selector: 'app-documenti-da-firmare-signal',
template: `
@if (store.caricamento()) {
<p>Caricamento…</p>
}
<ul>
@for (documento of store.daFirmare(); track documento.id) {
<li>
{{ documento.titolo }}
<button
(click)="store.firma(documento.id)"
[disabled]="store.inFirma().includes(documento.id)"
>
Firma
</button>
</li>
}
</ul>
`,
})
export class DocumentiDaFirmareSignal {
protected readonly store = inject(DocumentiStore);
}
Niente azioni e niente reducer: i metodi cambiano lo stato direttamente. Meno file e
meno codice. In cambio si perde il registro delle azioni, e con lui i DevTools: tra
@ngrx/signals e Redux DevTools non c’è un collegamento ufficiale. E il componente,
chiamando store.firma(id), sa cosa succederà: è un comando, non un evento.
Gli eventi anche in SignalStore
Da NgRx 19.2, il plugin Events porta il modello a eventi dentro SignalStore. Gli eventi si dichiarano come le azioni:
export const documentiPageEvents = eventGroup({
source: 'Documenti Page',
events: {
aperta: type<void>(),
firmaRichiesta: type<string>(),
},
});
Lo store li gestisce con un reducer, withReducer, e con gli handler,
withEventHandlers, che hanno il ruolo degli effetti:
withReducer(
on(documentiPageEvents.aperta, () => ({ caricamento: true, errore: null })),
on(documentiApiEvents.caricati, ({ payload: documenti }) => ({
documenti,
caricamento: false,
})),
// …
),
withEventHandlers(
(_, events = inject(Events), api = inject(DocumentiApi)) => ({
carica$: events.on(documentiPageEvents.aperta).pipe(
exhaustMap(() =>
api.elenco().pipe(
mapResponse({
next: (documenti) => documentiApiEvents.caricati(documenti),
error: (e: HttpErrorResponse) =>
documentiApiEvents.caricamentoFallito(e.message),
}),
),
),
),
// …
}),
),
Il componente invia gli eventi con injectDispatch(documentiPageEvents), che
restituisce un metodo per evento: dispatch.aperta(), dispatch.firmaRichiesta(id).
Due differenze con le azioni dello Store. Il tipo usa il nome così com’è scritto,
[Documenti Page] firmaRichiesta. E il dato viaggia in payload.
Quale scegliere
Prima domanda: serve uno store? Lo stato che appartiene a un componente solo, come una scheda selezionata o un pannello aperto, resta un signal nel componente.
Per il resto, la documentazione di NgRx propone il principio SHARI. Uno store globale conviene per lo stato che è:
- Shared, condiviso da molti componenti e servizi;
- Hydrated, salvato e ripreso da uno storage esterno;
- Available, che deve esserci ancora quando si torna su una rotta;
- Retrieved, che si ottiene con un effetto, per esempio una chiamata HTTP;
- Impacted, modificato da azioni che arrivano da altre parti.
La stessa pagina ammette il costo: NgRx Store non è pensato per essere il modo più breve di scrivere codice, e porta a molti file.
In pratica:
- NgRx Store per lo stato globale con tante fonti di modifica, quando servono il registro delle azioni e i DevTools, e in un team grande che trae vantaggio da convenzioni rigide.
- SignalStore per lo stato di una funzionalità o di una pagina, anche locale a un componente. È anche la libreria che la documentazione suggerisce quando non serve uno stato globale.
- SignalStore con Events quando più store devono coordinarsi, o quando si vuole il disaccoppiamento degli eventi senza lo Store globale.
Non si escludono: nella stessa app possono convivere lo Store per la sessione e le notifiche, e un SignalStore per pagina.
Why use NgRx Store for State Management?In breve
- Le azioni descrivono eventi, divisi per origine: la pagina, l’API.
- Il reducer è una funzione pura e crea ogni volta un oggetto nuovo. In produzione una modifica diretta non dà errori: la pagina semplicemente non si aggiorna.
- I selettori leggono e ricavano, con la memoization;
selectSignalli porta nei componenti come signal. - Gli effetti fanno il lavoro asincrono. Gli errori si gestiscono dentro la singola richiesta, o l’effetto si ferma senza dirlo.
switchMap,mergeMap,concatMap,exhaustMap: si sceglie in base a cosa deve succedere alla seconda richiesta.- SignalStore fa lo stesso lavoro con meno codice, ma senza registro delle azioni; il plugin Events riporta gli eventi.
- Uno store serve per lo stato condiviso; quello di un componente solo resta un signal.
Codice semplificato a scopo di esempio: nomi e struttura servono a dare l'idea, non sono da copiare così.
Guarda il post su Instagram