Gestione dello stato in Angular con NgRx: Store, effetti e SignalStore

· 14 min di lettura

Scritto per Angular 22 · NgRx 22

#tech#angular#ngrx

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/storeDocumentazione ufficiale · ngrx.io

L’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

documenti.actions.ts
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à.

Action GroupsDocumentazione ufficiale · ngrx.io

2. Lo stato e il reducer

documenti.feature.ts
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 cambia

La copia è superficiale: un array dentro l’oggetto non viene copiato, il nuovo oggetto punta allo stesso. Per questo nel reducer anche inFirma e documenti si ricreano, con [...state.inFirma, id], map e filter.

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 CreatorsDocumentazione ufficiale · ngrx.io Runtime checksDocumentazione ufficiale · ngrx.io

3. 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:

documenti.feature.ts
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.

SelectorsDocumentazione ufficiale · ngrx.io

4. Gli effetti

Il caricamento dei documenti:

documenti.effects.ts
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.

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.

LifecycleDocumentazione ufficiale · ngrx.io

La regola: gli errori si gestiscono dentro, sull’Observable della singola richiesta.

La firma, in parallelo

documenti.effects.ts
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
@ngrx/effectsDocumentazione ufficiale · ngrx.io OperatorsDocumentazione ufficiale · ngrx.io

5. La registrazione

app.config.ts
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.

@ngrx/store-devtoolsDocumentazione ufficiale · ngrx.io

6. I componenti

documenti-da-firmare.ts
@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:

badge-firme.ts
@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.

TestingDocumentazione ufficiale · ngrx.io

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/signalsDocumentazione ufficiale · ngrx.io

Ecco lo stesso stato, con le stesse operazioni:

documenti.store.ts
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:

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ì:

documenti-da-firmare-signal.ts
@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.

SignalStoreDocumentazione ufficiale · ngrx.io RxJS IntegrationDocumentazione ufficiale · ngrx.io

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:

documenti.events.ts
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:

documenti-eventi.store.ts
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.

EventsDocumentazione ufficiale · ngrx.io

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 è:

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:

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?Documentazione ufficiale · ngrx.io

In breve

  1. Le azioni descrivono eventi, divisi per origine: la pagina, l’API.
  2. 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.
  3. I selettori leggono e ricavano, con la memoization; selectSignal li porta nei componenti come signal.
  4. Gli effetti fanno il lavoro asincrono. Gli errori si gestiscono dentro la singola richiesta, o l’effetto si ferma senza dirlo.
  5. switchMap, mergeMap, concatMap, exhaustMap: si sceglie in base a cosa deve succedere alla seconda richiesta.
  6. SignalStore fa lo stesso lavoro con meno codice, ma senza registro delle azioni; il plugin Events riporta gli eventi.
  7. 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 @alessandroaiello.dev