Angular in 10 steps - 8 of 10

08 - Stato condiviso: i service come unica fonte di verità
Componenti, form, chiamate al server: i pezzi ci sono tutti. Ma prima o poi arriva la domanda che segna il passaggio dalle demo alle app vere: come fanno due componenti a condividere un'informazione?
Immagina: in una pagina un componente permette di selezionare un gioco, e da un'altra parte dell'interfaccia un secondo componente deve mostrarne i dettagli. Non sono padre e figlio, magari vivono in rami completamente diversi dell'app. Come si parlano?
La risposta è quella che ti ho anticipato alla fine del capitolo scorso: con un service. Nel capitolo 7 i service li abbiamo usati per le chiamate HTTP; oggi li vediamo nel loro secondo mestiere, che forse è anche il più importante: custodire lo stato dell'applicazione.
🧩 Il principio: lo stato vive in un posto solo
Ripasso lampo dal capitolo scorso: un service con providedIn: 'root' è un
singleton: Angular ne crea una sola istanza per tutta l'app e la
consegna, tramite la dependency injection, a chiunque la chieda con
inject().
Ed è proprio questa la chiave: se due componenti iniettano lo stesso service, stanno guardando lo stesso oggetto. Metti lo stato lì dentro, in un signal, e la condivisione viene da sola: chi scrive aggiorna il signal, chi legge si aggiorna automaticamente. È il pattern della singola fonte di verità: il dato abita in un posto solo, tutti gli altri lo osservano.
🛠️ Step 1: il service con lo stato
Costruiamo l'esempio del gioco selezionato. Prima versione, la più semplice possibile:
1// game-selection.service.ts
2import { Injectable, signal } from '@angular/core';
3
4@Injectable({ providedIn: 'root' })
5export class GameSelectionService {
6 selectedGame = signal<string | null>(null);
7
8 select(game: string) {
9 this.selectedGame.set(game);
10 }
11}Funziona già. Ma c'è una raffinatezza che nei progetti veri ripaga sempre:
così com'è, chiunque inietti il service può fare selectedGame.set(...) da
qualunque punto dell'app, e dopo sei mesi non sai più chi modifica cosa.
Meglio esporre il signal in sola lettura e far passare le scritture dai
metodi del service:
1import { Injectable, signal } from '@angular/core';
2
3@Injectable({ providedIn: 'root' })
4export class GameSelectionService {
5 private _selectedGame = signal<string | null>(null);
6
7 // il mondo esterno può leggere...
8 readonly selectedGame = this._selectedGame.asReadonly();
9
10 // ...ma per scrivere passa da qui
11 select(game: string) {
12 this._selectedGame.set(game);
13 }
14
15 clear() {
16 this._selectedGame.set(null);
17 }
18}Ora il service è l'unico punto dove la selezione può cambiare. Quando qualcosa non torna, sai esattamente dove guardare.
🧪 Step 2: il componente che scrive
Il selettore inietta il service e chiama il suo metodo:
1// game-selector.component.ts
2import { Component, inject } from '@angular/core';
3import { GameSelectionService } from './game-selection.service';
4
5@Component({
6 selector: 'app-game-selector',
7 template: `
8 <h3>Seleziona un gioco:</h3>
9 <button (click)="select('Zelda')">Zelda</button>
10 <button (click)="select('Metaphor')">Metaphor</button>
11 `
12})
13export class GameSelectorComponent {
14 private gameService = inject(GameSelectionService);
15
16 select(game: string) {
17 this.gameService.select(game);
18 }
19}👁️ Step 3: il componente che legge
Il dettaglio inietta lo stesso service e si limita a leggere il signal:
1// game-details.component.ts
2import { Component, inject } from '@angular/core';
3import { GameSelectionService } from './game-selection.service';
4
5@Component({
6 selector: 'app-game-details',
7 template: `
8 <h3>Gioco selezionato:</h3>
9 @if (selectedGame()) {
10 <p>{{ selectedGame() }}</p>
11 } @else {
12 <p>Nessun gioco selezionato</p>
13 }
14 `
15})
16export class GameDetailsComponent {
17 selectedGame = inject(GameSelectionService).selectedGame;
18}Fine. Clicchi "Zelda" nel primo componente e il secondo si aggiorna all'istante, senza che i due si conoscano: nessun riferimento diretto, nessun evento da propagare su per l'albero. Il service fa da punto d'incontro, i signals fanno il resto.
E lo stato derivato?
Anche i computed del capitolo 5 stanno benissimo dentro un service. Se il
dettaglio deve sapere se c'è una selezione, non serve un secondo stato da
tenere sincronizzato:
readonly hasSelection = computed(() => this._selectedGame() !== null);Stessa regola di sempre: tutto ciò che è derivabile si deriva, non si duplica.
Padre-figlio o lontani parenti?
Un service non è l'unico modo per far comunicare i componenti, ed è bene scegliere lo strumento in base alla distanza:
Tra padre e figlio diretti bastano gli input (li hai già visti nel capitolo 4 con
input()) e gli output per gli eventi verso l'alto. Il service entra in gioco quando i componenti sono lontani nell'albero, o quando lo stato deve sopravvivere alla navigazione: cambi pagina, torni indietro, e la selezione è ancora lì, perché il singleton vive quanto l'app.
📌 Quando usare un service per lo stato
- più componenti devono leggere o scrivere lo stesso valore
- lo stato deve sopravvivere alla navigazione tra pagine
- vuoi separare la logica dalla presentazione, e testarla senza montare componenti
E un'onestà dovuta: per la stragrande maggioranza delle app, service + signals è tutto quello che serve. Librerie di state management come NgRx hanno senso su progetti grandi, con team numerosi e requisiti particolari (undo/redo, tracciabilità di ogni cambiamento). Non partire da lì: parti dal service, e fatti convincere dal progetto a cambiare, non dalla moda.
Dove siamo arrivati
Il quadro dello stato è completo: i signals dentro i componenti per lo stato
locale, i service con signals per lo stato condiviso, asReadonly() per
proteggere le scritture e i computed per i valori derivati. Con questo
pattern in mano, la struttura di un'app Angular moderna non ha più segreti di
architettura.
Nel prossimo capitolo chiudiamo il modulo base con due attrezzi di finitura: gli interceptor, per mettere le mani su tutte le chiamate HTTP in un punto solo, e le pipe, per presentare i dati come si deve.