Gestione dello stato in Angular | Aulab
Riserva il tuo posto per settembre entro il 17/08
Sconti fino a 1.200€ sui corsi Digital & Tech! 👀 Iscriviti ora

GUIDE PER ASPIRANTI PROGRAMMATORI

Gestione dello stato in Angular

Nel precedente blocco di questa guida abbiamo sostanzialmente esaurito tutto quello che c’è da sapere in teoria per lavorare con Angular: gli esempi erano tutti circoscritti in modo da illustrare, una ad una, tutte le caratteristiche del framework front end. A partire da questa sezione, daremo per assodata la conoscenza di tali caratteristiche e vedremo…

Lezione 19 / 26
Enza Neri
Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

Nel precedente blocco di questa guida abbiamo sostanzialmente esaurito tutto quello che c’è da sapere in teoria per lavorare con Angular: gli esempi erano tutti circoscritti in modo da illustrare, una ad una, tutte le caratteristiche del framework front end. A partire da questa sezione, daremo per assodata la conoscenza di tali caratteristiche e vedremo esempi più mirati ad affrontare tematiche ricorrenti nella pratica nella programmazione.


Per cominciare, vediamo qualche esempio concreto di state management (gestione dello stato), cioè di come organizzare nel concreto proprietà e metodi di un componente (o direttiva, o pipe) in modo tale da descrivere al meglio:

  • quali informazioni il componente tratta;
  • quali avvenimenti causano il mutamento di queste informazioni e come;
  • come gestire gli effetti dei passaggi di stato.

Prima di tutto, vediamo le basi dello state management per come questo si fa tradizionalmente in OOP; i principi di base sono:

  • una proprietà per ogni dato di un determinato tipo;
  • un metodo per ogni evento che può innescare un cambiamento di stato.

Abbiamo visto un esempio molto chiaro di questa impostazione realizzando il counter component o il counter service. Eppure, nel mondo reale le cose sono un po’ più complicate di così: solitamente dobbiamo gestire dati asincroni, flag di errore, flag di caricamento, ecc..

Prima di proseguire, un’importante precisazione: tutto quello che vedremo in questo blocco della guida ha lo scopo di illustrare alcuni paradigmi che aiutano a gestire la complessità che un’applicazione può accumulare crescendo. È importante, tuttavia, notare che ogni paradigma porta con sé una quota di complessità per essere implementato, e dunque ha senso adottarlo solo se il costo del paradigma vale l’investimento. 

Immaginiamo, dunque, di avere una nostra entità, che potrebbe rappresentare un record di una tabella a database oppure lo stato di un servizio del quale stiamo programmando un pannello di controllo, eccetera.

Qualunque sia l’entità a cui ci riferiamo, essa sarà probabilmente recuperata come un oggetto o array json attraverso una chiamata HTTP:

type JsonObject = Record<string, string | number | boolean | null>;
type Entity = JsonObject | JsonObject[];

A questo punto, dobbiamo scrivere un componente che lavori su questa entità, caricandola e poi inviando dei comandi al backend, che potrebbero avere come payload l’entità stessa, una sua parte oppure altre informazioni provenienti dall’utente.

Il nostro entity.component.ts appare così:

import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';

type JsonObject = Record<string, string | number | boolean>;
type Entity = JsonObject | JsonObject[];

const entityUrl = 'mock_url';

@Component({
  selector: 'app-entity',
  standalone: true,
  imports: [JsonPipe],
  templateUrl: './entity.component.html',
  styleUrl: './entity.component.css'
})
export class EntityComponent implements OnInit {
  http = inject(HttpClient);
  entity: Entity | null = null;

  ngOnInit(): void {
    this.http.get<Entity>(entityUrl)
      .subscribe(entity => { this.entity = entity });
  }


  command() {
    this.entity = null;

    this.http.post<Entity>(entityUrl, { /* command payload */ })
      .subscribe(entity => { this.entity = entity });
  }
}

Fin qui è tutto abbastanza lineare, e possiamo facilmente intuire come scrivere entity.component.html:

@if (entity) {
  <pre>{{entity | json}}</pre>


  <div class="actions">
    <button (click)="command()">Command</button>
  </div>
} @else {
  <p>Loading...</p>
}

La logica che stiamo seguendo è: se la proprietà entity è null, allora dev’esserci un caricamento in corso. Questa è la ragione per cui la prima cosa che fa command() è svuotare entity.

Osserviamo anche che:

  • potremmo aggiungere tutti i commands che vogliamo;
  • potremmo astrarre http in un servizio;
  • entity potrebbe giungere al componente nella forma di un oggetto più complesso di un JsonObject, come una classe o altro.

Nessuna di queste cose cambierebbe sostanzialmente il nostro esempio, perciò proseguiamo.


Rappresentare lo stato di caricamento mediante l’assenza di un valore per entity è sintetico e funzionale, ma non è particolarmente descrittivo di quello che avviene realmente nel componente. Come programmatori informatici, dobbiamo pensare non solo a far funzionare le cose, ma anche a descrivere in modo chiaro le regole, i vincoli e i requisiti che le governano.

Perciò, una cosa che tipicamente va fatta è aggiungere una proprietà isLoading che funga da flag, e rappresentare il caricamento alzando e abbassando la flag:

 entity: Entity | null = null;
  isLoading: boolean = true;


  ngOnInit(): void {
    this.http.get<Entity>(entityUrl)
      .subscribe(entity => {
        this.isLoading = false;
        this.entity = entity;
      });
  }


  command() {
    this.isLoading = true;


    this.http.post<Entity>(entityUrl, { /* command payload */ })
      .subscribe(entity => {
        this.isLoading = false;
        this.entity = entity;
      });
  }

A questo punto, cercando le occorrenze di isLoading sarà sempre chiaro quando il componente è in caricamento e quando questo caricamento è terminato; anche nella vista, la logica diventa più esplicita:

@if (isLoading) {
  <p>Loading...</p>
} @else {
  <pre>{{entity | json}}</pre>


  <div class="actions">
    <button (click)="command()">Command</button>
  </div>
}

Un’altra cosa che può succedere è che i caricamenti falliscano; seguendo lo stesso approccio, possiamo aggiungere un’altra flag isError che funziona allo stesso modo:

 entity: Entity | null = null;
  isLoading: boolean = true;
  isError: boolean = false;


  ngOnInit(): void {
    this.http.get<Entity>(entityUrl)
      .subscribe({
        next: entity => {
          this.isLoading = false;
          this.entity = entity;
        },
        error: () => {
          this.isError = true;
          this.entity = null;
        }
      });
  }


  command() {
    this.isLoading = true;


    this.http.post<Entity>(entityUrl, { /* command payload */ })
      .subscribe({
        next: entity => {
          this.isLoading = false;
          this.entity = entity;
        },
        error: () => {
          this.isError = true;
          this.entity = null;
        }
      });
  }


Ora nella view possiamo considerare il nuovo stato di errore:

@if (isLoading) {
  <p>Loading...</p>
} @else if (isError) {
  <p class="error">Something went wrong!</p>
} @else {
  <pre>{{entity | json}}</pre>


  <div class="actions">
    <button (click)="command()">Command</button>
  </div>
}

A questo punto, sembra che abbiamo coperto tutti i casi correttamente. Eppure, se volessimo essere ambiziosi (ma neanche troppo), dovremmo ipotizzare che un utente voglia avere la possibilità di recuperare il controllo a partire da uno stato d’errore, per esempio ricaricando il componente:

@if (isLoading) {
  <p>Loading...</p>
} @else if (isError) {
  <p class="error">Something went wrong!</p>
  <button (click)="ngOnInit()">Reload</button>
} @else {
  <pre>{{entity | json}}</pre>


  <div class="actions">
    <button (click)="command()">Command</button>
  </div>
}

Qui emerge una piccola falla nel nostro design iniziale: lo stato è inizializzato in costruzione anziché on init, perciò dobbiamo modificare ngOnInit per ribadire quale debba essere lo stato iniziale:

  ngOnInit(): void {
    this.isLoading = true;
    this.isError = false;
    this.entity = null;


    this.http.get<Entity>(entityUrl)
      .subscribe({
        next: entity => {
          this.isLoading = false;
          this.entity = entity;
        },
        error: () => {
          this.isError = true;
          this.entity = null;
        }
      });
  }

E, dunque, per evitare di ripeterci, ci conviene anche aggiustare il modo in cui inizializziamo lo stato:

  entity!: Entity | null;
  isLoading!: boolean;
  isError!: boolean;

Che cosa significa quanto visto?
Ci siamo limitati a dichiarare le proprietà del nostro stato, e abbiamo silenziato gli avvisi di TypeScript con delle asserzioni di non-nullità. Volendo tradurre, abbiamo detto a TypeScript “non preoccuparti del fatto che queste proprietà siano tutte undefined in un primo momento, perché verranno comunque inizializzate e rispetteranno sempre il tipo annotato”.

A questo punto, possiamo solo finalizzare il nostro design OOP, delegando l’impostazione dello stato a dei metodi privati che ne garantiscano anche la consistenza in tutti i passaggi, eliminando anche le ripetute assegnazioni.

Il nostro entity.componnent.ts finale ha questo aspetto:

import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';


type JsonObject = Record<string, string | number | boolean>;
type Entity = JsonObject | JsonObject[];

const entityUrl = 'mock_url';

@Component({
  selector: 'app-entity',
  standalone: true,
  imports: [JsonPipe],
  templateUrl: './entity.component.html',
  styleUrl: './entity.component.css'
})
export class EntityComponent implements OnInit {
  http = inject(HttpClient);

  entity!: Entity | null;
  isLoading!: boolean;
  isError!: boolean;

  ngOnInit(): void {
    this.#loading();

    this.http.get<Entity>(entityUrl)
      .subscribe({
        next: entity => this.#update(entity),
        error: () => this.#error()
      });
  }

  command() {
    this.#loading();

    this.http.post<Entity>(entityUrl, { /* command payload */ })
      .subscribe({
        next: entity => this.#update(entity),
        error: () => this.#error()
      });
  }

  #loading(): void {
    this.entity = null;
    this.isLoading = true;
    this.isError = false;
  }

  #update(entity: Entity): void {
    this.entity = entity;
    this.isLoading = false;
    this.isError = false;
  }

  #error(): void {
    this.entity = null;
    this.isLoading = false;
    this.isError = true;
  }
}

Questo design è piuttosto ottimale perchè:

  • dichiariamo le proprietà che compongono lo stato una volta sola,
  • fissiamo tutte le combinazioni sensate del nostro stato con dei metodi privati,
  • usiamo dei metodi pubblici per innescare le operazioni e deleghiamo ai metodi privati l’impostazione corretta dello stato, evitando stati inconsitenti e ripetizioni.

Eppure, se osserviamo entity.component.html, possiamo notare che c’è ancora un problema, non esattamente da poco: stiamo facendo affidamento su dei vincoli che non sono formalizzati realmente da nessuna parte, se non nei metodi privati della component class. Chi ci garantisce che non esista uno stato in cui isError e isLoading siano false, ma entity sia comunque null e, dunque, non ci sia nulla da mostrare nel blocco @else finale?

Dal momento che siamo in un’architettura OOP, dobbiamo presumere che una classe si prenda cura dei suoi stati possibili, perciò dovremmo correttamente incapsulare le proprietà dello stato in modo che siano modificabili solo dall’interno della classe, ma questo ci complicherebbe la vita senza risolvere realmente il problema: noi vogliamo esprimere una combinazione esaustiva di tutti e soli gli stati possibili.

Vediamo, allora, come riprogettare il nostro entity component, usando i seguenti concetti:

  • una tagged union o discriminated union, cioè un tipo di tipo che è possibile dichiarare in TypeScript;
  • la macchina a stati finiti, cioè un modello matematico che descrive un sistema che può assumere un numero finito di condizioni con proprietà peculiari.

Cominciamo con il formalizzare tutte le condizioni che il nostro stato può assumere:

type State =
  | { type: 'loading' | 'error' }
  | { type: 'update', entity: Entity };

Poiché gli stati di loading e error sono mutualmente esclusivi, possiamo collassare più flag in un unico tag, cioè la proprietà type; in più, possiamo taggare lo stato che rappresenta l’aggiornamento della entity come update, e attribuirvi un payload.

Notiamo come questo renda incredibilmente più facile andare ad aggiungere un payload anche sulle altre condizioni dello stato: potremmo aggiungere un progress: number su loading e un message su error senza realmente aggiungere complessità alla gestione dello stato sul componente e, soprattutto, senza doverci preoccupare dei possibili valori null di proprietà che esistono solo in alcune condizioni.

Ora che abbiamo ridefinito il nostro stato, ci basterà dichiararlo così nel componente:

state!: State;

E, poiché abbiamo già una descrizione esaustiva e vincolante dei nostri stati possibili, possiamo disfarci dei nostri metodi privati e riscrivere il nostro componente così:

import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';

type JsonObject = Record<string, string | number | boolean>;
type Entity = JsonObject;

type State =
  | { type: 'loading' | 'error' }
  | { type: 'update', entity: Entity };

const entityUrl = 'mock_url';

@Component({
  selector: 'app-entity',
  standalone: true,
  imports: [JsonPipe],
  templateUrl: './entity.component.html',
  styleUrl: './entity.component.css'
})
export class EntityComponent implements OnInit {
  http = inject(HttpClient);

  state!: State;

  ngOnInit(): void {
    this.state = { type: 'loading' };

    this.http.get<Entity>(entityUrl)
      .subscribe({
        next: entity => { this.state = { type: 'update', entity } },
        error: () => { this.state = { type: 'error' } }
      });
  }

  command() {
    this.state = { type: 'loading' };

    this.http.post<Entity>(entityUrl, { /* command 1 payload */ })
      .subscribe({
        next: entity => { this.state = { type: 'update', entity } },
        error: () => { this.state = { type: 'error' } }
      });
  }
}

Questo approccio ha una serie di vantaggi:

  • potremmo comunque avere dei metodi a cui delegare l’impostazione dello stato, ma anche se non lo facciamo, TypeScript ci impedirà di usare entity in uno stato che non sia type: ‘update’
  • il nostro stato è sempre discriminato da un’unica proprietà, e dunque è anche più facile capire in che condizione ci troviamo senza rischiare di perdersi qualcosa.

Quest’ultimo punto ci torna particolarmente utile per riscrivere entity.component.html:

@switch (state.type) {
  @case ('loading') {
    <p>Loading...</p>
  }
  @case ('error') {
    <p class="error">Something went wrong!</p>
    <button (click)="ngOnInit()">Reload</button>
  }
  @case ('update') {
    <pre>{{state.entity | json}}</pre>


    <div class="actions">
      <button (click)="command()">Command 1</button>
    </div>
  }
}

Ora sì che abbiamo una copertura esaustiva dei casi e nessuna possibilità d’errore: al di fuori del caso update, la proprietà entity non esiste e, dunque, non corriamo il rischio di leggere un valore nullo!

Hai bisogno di informazioni? 🙋🏻‍♂️

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626