Angular e state manager: quando e perché | Antreem
Vai al blog

Angular e state manager: quando e perché

di lettura
Massimo Artizzu
thumbnail

Col crescere di un’applicazione, cresce anche la quantità di dati che essa deve gestire e fornire alle sue “viste”. Questi dati costituiscono quel che si definisce stato interno, e si può dire che l’aspetto di un’applicazione, in un qualsiasi istante del suo ciclo di vita, sia l’immagine del suo stato interno.

Lo “stato interno” può essere costituito dalle informazioni più disparate: dal nome dell’utente loggato, al contenuto dei campi di ricerca; dai risultati della suddetta ricerca allo stato di apertura del menù laterale; dal path relativo dell’applicazione web sino anche al contenuto delle label dei campi. È subito chiaro come diverse di queste informazioni abbiano un carattere puramente “locale” (ad esempio, il contatore che riporta il numero di risultati di ricerca ottenuti), altri invece abbiano un impatto trasversale (come l’impostazione del tema stilistico); altri dati sono gestiti nativamente dagli elementi HTML del web, sia di effetto locale (come la presenza del “focus” su un campo di immissione), sia globale (come l’URL dell’applicazione), in contrapposizione con i dati che devono essere gestiti specificamente dall’applicazione.

Il modo classico e più immediato di gestire dati con impatto multi-funzionale (o anche solo multi-componente) in un’applicazione Angular consiste nella creazione di un servizio, come il seguente che si occupa di gestire il login e le informazioni di profilo dell’utente:

@Injectable()
export class ProfileService {
  private readonly profileSource$
    = new BehaviorSubject(null);
  readonly profile$ = this.profileSource$.asObservable();

  login(user: string, password: string) {
    this.http.post(LOGIN_URL, { body: { user, password }})
      .subscribe(profile => {
        this.profileSource$.next(profile);
      });
  }
}

In questo modo, tutti i componenti, le direttive, le pipe e altri servizi che hanno necessità delle informazioni di profilo dovranno aggiungere come propria dipendenza ProfileService e attingere all’observable profile$. Ad esempio:

@Component({
  selector: 'main-header',
  template: '<header>{{ name$ | async }}</header>'
})
export class MainHeaderComponent {
  readonly name$ = this.profileService.profile$
    .pipe(map(profile => profile.name));

  constructor(private readonly profileService: ProfileService) {}
}

I problemi nascono quando questi tipi di dati cominciano a diventare in quantità considerevole, perché questo comporta un moltiplicarsi delle dipendenze delle entità Angular dell’applicazione. A ogni dipendenza, si genera un accoppiamento dell’entità col servizio di gestione, rendendo il primo meno esportabile e meno testabile, e il secondo meno eliminabile/modificabile.

Un direttore che impartisce direttive ai suoi sottoposti, radunati intorno

Il concetto equivalente è quello di una direzione dipartimentale che raduna alcuni suoi sottoposti, e asserisca: “Ho bisogno che A mi dia X, B faccia Y, C mi passi Z…” Si tratta di una strategia perseguibile, ma non sostenibile con l’estendersi dei “dipartimenti” – cioè delle funzionalità. Può anche capitare che il direttore – stanco di dover assumere sottoposti per fare compiti specifici – si rassegni a “fare le cose da solo”, uscendo quindi dalle mansioni. Nella nostra metafora equivale ad avere un componente che esegue compiti strettamente non legati alla rappresentazione della “vista”, come una chiamata al backend o manipolazione di dati.

A lungo andare è facile quindi immaginare che le posizioni direttive facciano uso di una differente “catena di comando”.

L’idea dietro ad uno state manager

Uno state manager consiste nell’avere un “magazzino” centralizzato per lo stato interno dell’applicazione. Se questo può sembrare inefficiente e dispersivo, è forse perché abbiamo in mente l’immagine di un immenso schedario relegato in un freddo scantinato e percorso solo da pochi eletti che riescono a raccapezzarsi. Questo distaccamento dai “piani alti” non sussiste più quando si tratta di applicazioni, e lo state manager è un elemento sempre più fondamentale nello sviluppo delle applicazioni moderne.

Il punto fondamentale è che i nostri componenti avranno sempre solo un’unica fonte di verità – il nostro “magazzino di dati” (store) – da cui attingeranno i dati e a cui inoltreranno le proprie richieste. Questo significa che un componente potrebbe avere una sola dipendenza, cioè il nostro store.

Per riprendere la nostra metafora, è come se la direzione convocasse uno scrupolosissimo segretario e gli comunicasse: “Ho bisogno di X e Z, e che si faccia Y…” Il compito del segretario sarà quello di smistare le richieste e gli aggiornamenti a chi di dovere, aggiornando i dati dell’archivio e riportando puntualmente quanto richiesto.

Con un segretario così efficiente, passerà la voglia di fare le cose per conto proprio!

Lo stesso direttore che impartisce direttive alla segreteria come unico canale

 

Il pattern Redux

Redux è forse il più noto state manager del mondo frontend web. Ideato da Dan Abramov per applicazioni React, si è poi diffuso come concezione anche in altri framework, ed è il motivo per cui oggi si parla di pattern Redux. La versione originale di Redux consisteva in poco più di un centinaio di righe di codice!

Il concetto alla base di Redux è quello di azione: essa esprime un intento, che può consistere nell’aggiornare lo stato interno con un certo set di dati, ma anche nell’invocare una chiamata a back-end e via dicendo. Alle azioni rispondono i riduttori, che non sono altro che funzioni di trasformazione dello stato interno in un nuovo stato, usando i dati forniti con le azioni. Infine, i dati vengono estratti dallo store tramite uso di connettori che passano i risultati ai componenti, dopo eventuali trasformazioni.

A corredo di ciò, ci sono i middleware, cioè degli hook che operano trasformazioni sulle azioni e che si possono occupare della gestione dei side effect, vale a dire operazioni come chiamate al backend, comunicazione con device e via dicendo.

Ci sono alcuni punti importanti nello sviluppo di applicazioni con il pattern Redux:

  • riduttori e connettori devono essere funzioni pure, cioè deterministiche e prive di side effect;
  • aggiornare lo stato interno non consiste nel mutare le sua proprietà, ma nel sostituirlo interamente con una copia di esso con le proprietà aggiornate;
  • opzionalmente – ma de facto standard per motivi di tooling e caching – azioni e stato interno devono essere completamente serializzabili.

Redux per Angular: NgRx

Per quanto esista un progetto (sebbene non più mantenuto) per l’uso di Redux in Angular, la libreria sicuramente più diffusa che implementa il pattern Redux nel mondo Angular è sicuramente NgRx. Si tratta di un’implementazione largamente basata sugli observable e quindi perfettamente in linea con la filosofia di sviluppo di Angular. Inoltre offre di base:

  • un sistema nativo di separazione dei feature store, compatibile col lazy-loading di moduli;
  • l’applicazione automatica della memoizzazione ai selettori (i connettori in ottica NgRx);
  • un pacchetto ufficiale per la gestione dei side effect (o effetti, in termini di NgRx);
  • un altro pacchetto per il binding con l’unico altro sistema di gestione di stato di Angular, cioè il router;
  • degli schematics già pronti per la creazione delle entità di NgRx.

A questo ci possiamo aggiungere altri vantaggi:

  • una community intorno al progetto vasta ed esperta;
  • una sempre maggiore usabilità e minore verbosità d’utilizzo;
  • la compatibilità con gli utilissimi strumenti di sviluppo per Redux;
  • l’installazione della libreria e dei pacchetti collegati è semplice grazie all’integrazione con ng add.

 

NgRx in azione

Schema di flusso di funzionamento di NgRx
Concept grafico di NgRx (dalla home di NgRx)

Supponiamo di avere un flusso classico di login di un utente, dove a seguito dell’invio di nome utente e password da una maschera di accesso viene chiamato il backend, il quale restituisce le informazioni di profilo dell’utente. In termini di NgRx, dovremo creare una azione di questo tipo:

export const loginAction = createAction('Login',
  props<{ username: string; password: string }>());

Questa azione verrà lanciata (dispatch) dal componente della maschera di login al momento della sottomissione del form:

@Component({ ... })
export class LoginComponent {
  readonly form = new FormGroup({
    username: new FormControl('', Validators.required),
    password: new FormControl('', Validators.required)
  });

  constructor(private readonly store: Store) {}

  handleLogin() {
    this.store.dispatch(loginAction(this.form.value));
  }
}

A questo punto, l’azione verrà intercettata da un effetto, il quale chiamerà il backend per effettuare il login. Al ritorno di tale chiamata a backend, l’effetto “trasformerà” l’azione loginAction in un’altra azione, che indichi il successo della chiamata; oppure il suo fallimento. Quindi, definiremo altre due azioni:

export const loginSuccessAction = createAction('Login Success',
  props<{ profile: Profile }>());
export const loginFailureAction = createAction('Login Failure',
  props<{ error: string }>());

E l’effetto che gestirà queste tre azioni sarà simile a questo:

@Injectable()
export class ProfileService {
  readonly login$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loginAction),
      exhaustMap(({ username, password }) =>
        this.profileService.login(username, password).pipe(
          map(profile => loginSuccessAction({ profile })),
          catchError(error => of(loginFailureAction({ error })))
        )
      )
    )
  );

  constructor(
    private readonly actions$: Actions,
    private readonly profileService: ProfileService
  ) {}
}

A questo punto entrerà in gioco il riduttore, che processerà adeguatamente le informazioni ricevute:

const initialState: ProfileState = {
  profile: null,
  loginError: null,
}
export const profileReducer = createReducer(
  initialState,
  on(loginSuccessAction, (state, action) => ({
    ...state, // "copia" lo stato in un nuovo oggetto
    profile: action.profile,
    loginError: null
  })),
  ...
);

In ultimo, le informazioni di profilo (o di errore di login) possono essere recuperate dal selettori e usate nei componenti. Qui ne definiamo alcuni, il primo dei quali servirà a selezionare il nostro feature state, e gli altri sono creati componendo questo o altri selettori per crearne uno nuovo:

export const selectProfileState = createFeatureSelector('profile');
export const selectProfile =
  createSelector(selectProfileState, state => state.profile);
export const selectProfileName =
  createSelector(selectProfile, profile => profile?.name);

Per visualizzare il nome dell’utente nell’intestazione della pagina:

@Component({
  selector: 'main-header',
  template: '<header>{{ name$ | async }}</header>'
})
export class MainHeaderComponent {
  readonly name$ = this.store.select(selectProfileName);

  constructor(private readonly store: Store) {}
}

Da questo punto in poi, qualsiasi cambiamento alla proprietà profile dello stato verrà riportata nell’observable ottenuto usando il metodo select dello store.

State manager: quando sono consigliabili

Il più grande scoglio da affrontare quando si comincia ad usare uno state manager è rappresentato sicuramente dall’inversione del flusso classico di richiesta e trasformazione dati: si riduce e separa la logica imperativa per implementarne una dichiarativa. Questo può essere spiazzante, e se ci aggiungiamo il fatto che ci sembra di scrivere più codice boilerplate di quanto fatto precedentemente, è facile pensare che non ne possa valere la pena.

Ma, a ben guardare, a dover creare un servizio Angular che ci faccia da state manager per una singola funzionalità non è certamente meglio, e anzi alla lunga produce più boilerplate rispetto alla classica organizzazione di NgRx, che oltretutto può essere semplificata dall’uso degli schematics associati (mentre lo stesso si può dire per la creazione di servizi).

Il vero vantaggio di usare NgRx, però, è probabilmente il seguente: si è portati in maniera naturale a scrivere codice ordinato, con una naturale separazione di compiti. Una volta che si entra nell’ottica dello sviluppo concorde con uno state manager, è difficile farne a meno.

“Separazione dei compiti” comporta che le varie fasi di invio e trasformazione dell’informazione sono debolmente accoppiate, e offrono molteplici possibilità di estensione delle funzionalità. Se, ad esempio, avessimo voluto mostrare uno “spinner” durante l’invio della richiesta di login al server, avremmo semplicemente aggiunto una proprietà booleana allo stato, che avremmo poi gestito nel riduttore. Un selettore avrebbe recuperato il dato per la maschera di login.

Tutto questo sarebbe avvenuto senza toccare la logica preesistente. Logiche più isolate aiutano anche l’onboarding nel team di nuovi membri.

Se ci aggiungiamo che anche i test unitari per applicazioni con state manager vengono significativamente semplificati, l’adozione di una tale libreria è quasi una scelta da fare ad occhi chiusi.

State manager: quando si può farne a meno

Se prima si è evidenziato come le dipendenze dei componenti siano sempre dei vincoli dai quali è difficile liberarsi, questo vale anche a livello di progetto. E NgRx non viene gratis, ma dovrà essere installata nel progetto.

In sostanza, se il progetto ha almeno una delle seguenti caratteristiche:

  • è di piccole dimensioni;
  • ha funzionalità fortemente isolate;
  • è sviluppato da una sola persona,

fare le cose alla “vecchia maniera” può essere una strada accettabile.

In definitiva

Passare all’uso di uno state manager può essere una sfida importante, specialmente se non si è mai affrontata. Sicuramente è bene impostare un progetto da subito per l’uso di uno state manager, perché rifattorizzarlo in un secondo momento può essere un’impresa proibitiva se non con progetti a lunga manutenzione.

Tuttavia, l’utilizzo di state manager è ormai considerato un elemento essenziale nella costruzione di architetture, tant’è che un framework moderno per lo sviluppo di applicazioni web come Vue ne offre uno ufficiale (Vuex), mentre altri – come React e Svelte – offrono strumenti fondamentali per la gestione dello stato.

In Antreem l’adozione di NgRx si sta diffondendo con sempre maggiore successo, anche per introdurre un modus operandi comune delle applicazioni web moderne, e abituare ad astrarre i concetti operativi tra i vari attori che ne compongono il funzionamento.

Massimo Artizzu
Scritto da
Massimo Artizzu
Formato in Matematica, ma adottato professionalmente nella programmazione, un pallino avuto sin da piccolo. Amante di enigmi e origami, giocatore di rugby per diletto.