
GUIDE PER ASPIRANTI PROGRAMMATORI
Signals in Angular
Con gli observables abbiamo toccato un argomento importantissimo per tutti i framework che si occupano di UI: l’onore e l’onere di sapere quando un dato cambia nello stato per poterlo aggiornare sul DOM. Questa operazione prende il nome di change detection (rilevamento delle modifiche) e in Angular può essere eseguita con diverse strategie che offrono…


Vuoi avviare una nuova carriera o fare un upgrade?
Trova il corso Digital & Tech più adatto a te nel nostro catalogo!
- Le direttive in Angular
- I componenti in Angular
- Il template in Angular
- Le direttive strutturali in Angular
- La content projection in Angular
- I servizi in Angular
- Le Pipes in Angular
- Routing in Angular
- Invio di form in Angular
- Built-in control flow in Angular
- Deferrable views in Angular
- Image optimization in Angular
- Standalone components in Angular
Con gli observables abbiamo toccato un argomento importantissimo per tutti i framework che si occupano di UI: l’onore e l’onere di sapere quando un dato cambia nello stato per poterlo aggiornare sul DOM. Questa operazione prende il nome di change detection (rilevamento delle modifiche) e in Angular può essere eseguita con diverse strategie che offrono diversi livelli di comodità ed efficienza.
Per capire come funziona la change detection, immaginiamo di scrivere il nostro framework basato su componenti:
- ogni componente ha uno stato;
- lo stato dev’essere riflesso sulla UI, sia in fase di inizializzazione, sia ad ogni successivo aggiornamento;
- la UI a sua volta scatena degli eventi dai quali possono seguire logiche di aggiornamento dello stato, sincrone o asincrone.
Per fare questo abbiamo essenzialmente due possibili strategie:
- intercettare ogni possibile evento che può avvenire in una web app: eventi dalla UI, risposte HTTP, setInterval, setTimeout, ecc; per farlo dovremmo aggiungere degli event listeners sul DOM, ma anche sostituire tutte le API del linguaggio JavaScript che chiamano callback (come le due appena citate);
- offrire un unico punto di ingresso per l’aggiornamento dello stato, obbligando il programmatore a notificare al framework che lo stato è cambiato.
La strategia di Angular corrisponde al primo metodo, che viene applicato usando una libreria chiamata Zone.js.
Come il nome suggerisce, Zone crea delle zone in cui tutto ciò che può succedere è posto sotto osservazione; ogni volta che accade qualcosa il nuovo stato viene scansito in cerca di differenze. Questo significa che quando lavoriamo con Angular abbiamo continuamente a che fare con API proxy che assomigliano in tutto e per tutto a quelle originali, ma in realtà sono state sovrascritte (questo è possibile perché in JavaScript qualunque variabile o funzione globale può essere riassegnata).
Ovviamente l’intera architettura volta a consentire ad Angular questo tipo di change detection è computazionalmente piuttosto costosa, decisamente più della seconda strategia menzionata prima; tuttavia, offre allo sviluppatore la comodità di non doversi occupare di notificare al framework che lo stato è cambiato, se non in situazioni particolarmente specifiche.
Di recente, nel mondo frontend è tornato alla ribalta il concetto di signal (segnale): un signal è un tipo di dato in grado di notificare le modifiche al framework tenendo traccia dei punti in cui il suo valore viene letto.
In pratica, quando un framework utilizza un sistema di segnali, l’onere della change detection si sposta all’interno dei segnali stessi, risparmiando al framework l’onere di dover scansire lo stato in cerca di modifiche e di identificare le parti di UI da aggiornare.
Ma come è possibile tutto questo?
Tanto per cominciare, i segnali non sono direttamente leggibili ma il loro valore è accessibile solo tramite una getter function. Il compito di questa funzione è duplice: da un lato espone il valore corrente del segnale, dall’altro traccia dove il segnale è usato.
In secondo luogo, i segnali non sono nemmeno direttamente scrivibili; a dire il vero, un segnale potrebbe anche non essere scrivibile affatto. Ovviamente, quando modifichiamo il valore di un segnale attraverso la sua setter function, stiamo anche notificando la modifica di stato al framework.
In pratica, tramite i segnali, Angular passa dalla prima alla seconda strategia usando un’architettura che a tutti gli effetti è molto simile a quella degli observable; non a caso, in Angular è presente un’API di conversione da observable a signal e viceversa.
Viste le molte somiglianze tra un’architettura basata su observable e una basata su signal, il modo migliore per esemplificare il tutto è proprio quello di reimplementare la web app della sezione precedente utilizzando proprio i segnali; questo ci darà l’opportunità di assaggiare l’API, i casi d’uso, le similitudini e l’interoperabilità tra i due approcci.
Cominciamo da TodoService:
import { Injectable, signal } from '@angular/core'; export interface Todo { label: string; done: boolean; } export const Todo = (label: string = ''): Todo => ({ label, done: false }); @Injectable({ providedIn: 'root' }) export class TodoService { readonly todos = signal<Todo[]>([]); notifyChanges(): void { this.todos.update(x => [...x]) } }
Qui la proprietà todos non è più un BehaviorSubject ma un signal creato con l’apposita funzione. Poiché un signal non può notificare una modifica se il suo valore è identico, il metodo notifyChanges propaga una copia dell’array come nuovo valore.
Usando un signal per rappresentare il nostro stato non abbiamo più bisogno di far convivere un’API sinrona e una reattiva, perciò non abbiamo bisogno di getter e setter.
Vediamo ora come cambia TodoPersistComponent:
import { afterNextRender, Component, computed, effect, inject, OnDestroy, signal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { Todo, TodoService } from '../todo.service'; import { debounceTime, Observable, switchMap, tap } from 'rxjs'; @Component({ selector: 'app-todo-persist', standalone: true, imports: [], template: '', }) export class TodoPersistComponent { constructor() { const $ = inject(TodoService); const loadedTodos = signal<Todo[] | undefined>(undefined); const todosSignal = computed( () => loadedTodos() ? $.todos() : undefined ); const debouncedSignal = toSignal( toObservable(todosSignal).pipe(debounceTime(200)) ); afterNextRender(() => { const todos = localStorage.getItem('todos') ?? '[]'; loadedTodos.set(JSON.parse(todos)); }); effect( () => { const loaded = loadedTodos(); if (loaded) $.todos.set(loaded); }, { allowSignalWrites: true } ); effect(() => { const todos = debouncedSignal(); if (todos) localStorage.setItem('todos', JSON.stringify(todos)); }); } }
Qui ce la caviamo al 100% con il solo costruttore, poiché i segnali sono automaticamente agganciati al ciclo di vita del componente e dunque non dobbiamo tenere traccia delle sottoscrizioni manualmente.
Vediamo che succede nel dettaglio.
Anzitutto iniettiamo TodoService direttamente come variabile. Dopodiché creiamo un segnale loadedTodos che inizializziamo ad undefined e che setteremo con il valore recuperato in afterNextRender; in pratica, anziché ritornare un observable che emetterebbe un valore solo quando pronto, stiamo proceduralmente creando un segnale “vuoto” che poi “riempiremo”; la reattività qui c’è ancora, perché Angular a quel punto terrà conto del fatto che il valore è cambiato, ma ora non siamo noi a gestirla.
In poche parole, loadedTodos svolge il ruolo dell’observable restituito dal metodo #load della sezione precedente.
A questo punto deriviamo da loadedTodos un altro segnale che assume valore undefined se loadedTodos non ha valore, altrimenti rappresenta i nostri todos. Qui i todos vengono estratti dal signal (notare le parentesi tonde), perciò stiamo facendo una cosa simile a quello che nell’esempio precedente abbiamo fatto con switchMap, solo che qui avviene in modo procedurale.
Ora dobbiamo aggiungere la logica di debounce e per questo ci avvaliamo ancora di RxJS. Per farlo, facciamo andata e ritorno da signal a observable usando pipe(debounceTime(200)) nel mezzo.
Va notato come queste trasformazioni consecutive fossero molto più semplici non dovendo fare avanti e indietro da un’API all’altra; probabilmente, in futuro Angular si doterà di un’API di trasformazione dei signal che non renda necessario passare da RxJS.
Una volta definiti i nostri segnali, andiamo ad applicare della logica:
- afterNextRender funziona come nell’esempio reattivo, ma chiama .set anziché .next e non ha bisogno di completare;
- il primo effect legge il valore di loadedTodos, quindi diventa reattivo rispetto ai suoi aggiornamenti di stato; ad ogni valore emesso, se presente, lo mette dentro i todos del nostro TodoService, normalmente un effetto non può modificare il valore di altri segnali, ma possiamo abilitare questa cosa con l’opzione allowSignalWrites;
- il secondo effect reagisce a debouncedSignal salvando il suo valore a localStorage ad ogni aggiornamento.
Se ragionare in modo reattivo poteva risultare un cambio di forma mentis impegnativo, questo potrebbe risultare ancora più astruso. Evidentemente ci sono cose per cui risulta più lineare la strada dei signal e altre per cui è meglio rimanere in contesto observable; qui si voleva semplicemente esemplificare una possibile reimplementazione dell’esempio reattivo.
In entrambi i casi è utile spostare il focus della logica dal classico “se-allora” a “cosa cambia in relazione a cosa”.
Ora migriamo anche TodoFindService:
import { computed, inject, Injectable, signal } from '@angular/core'; import { TodoService } from '../todo.service'; @Injectable({ providedIn: 'root' }) export class TodoFindService { $ = inject(TodoService); readonly filter = signal(''); readonly found = computed(() => ( this.$.todos().filter(t => t.label.includes(this.filter())) )); }
Anche questo si è semplificato rispetto alla sua controparte reattiva: i nostro filtro è ora un semplice signal, che verrà usato direttamente dal relativo componente, e found è ricavato con la stessa logica, ma senza dover esplicitamente indicare da quali altri dati deriva; il fatto che chiamiamo i getter su this.$.todos() e this.filter() sarà sufficiente a mettere in relazione found con i due signal da cui dipende.
Passiamo a TodoFindComponent:
import { Component, inject } from '@angular/core'; import { TodoFindService } from './todo-find.service'; @Component({ selector: 'app-todo-find', standalone: true, imports: [], template: ` <div style="margin: 8px 0;"> <input type="text" placeholder="search..." (input)="update($event)"> </div> ` }) export class TodoFindComponent { $ = inject(TodoFindService); update(event: Event): void { const target = event.currentTarget as HTMLInputElement; this.$.filter.set(target.value); } }
Qui è cambiato veramente poco: anziché chiamare TodoFindService.update, usiamo direttamente il setter del signal $.filter.set(…) per aggiornare il filtro.
Veniamo ora al TodoListComponent che mostra il tutto:
import { Component, inject } from '@angular/core'; import { Todo, TodoService } from '../todo.service'; import { TodoFindService } from '../todo-find/todo-find.service'; @Component({ selector: 'app-todo-list', standalone: true, imports: [], templateUrl: './todo-list.component.html' }) export class TodoListComponent { $t = inject(TodoService); $f = inject(TodoFindService); add(): void { this.$t.todos.update(todos => todos.concat(Todo())); } remove(todo: Todo): void { this.$t.todos.update(todos => todos.filter(t => t != todo)); } updateDone(todo: Todo, event: Event): void { const target = event.currentTarget as HTMLInputElement; todo.done = target.checked; this.$t.notifyChanges(); } updateLabel(todo: Todo, event: Event): void { const target = event.currentTarget as HTMLInputElement; todo.label = target.value; this.$t.notifyChanges(); } }
Anche qui è cambiato veramente poco: usiamo il metodo update sui segnali per modificare il loro stato in relazione allo stato precedente, e notifyChanges per notificare un aggiornamento di stato là dove abbiamo mutato gli elementi senza riassegnare l’intero array.
@for (t of $f.found(); track $index) { <div style="margin: 8px 0; display: flex; gap: 4px;"> <input type="checkbox" [checked]="t.done" (input)="updateDone(t, $event)" > <input type="text" placeholder="add label" [value]="t.label" (input)="updateLabel(t, $event)" > <button (click)="remove(t)">Remove</button> </div> } <button (click)="add()">Add</button>
In questo caso l’unica differenza è che abbiamo chiamato il getter del segnale found direttamente nel template anziché usare la AsyncPipe.
Infine il nostro SummaryComponent:
import { Component, computed, inject } from '@angular/core'; import { Todo, TodoService } from '../todo.service'; import { JsonPipe } from '@angular/common'; import { TodoFindService } from '../todo-find/todo-find.service'; interface Summary { total: number; todo: number; done: number; } const Summary = (todos: Todo[]): Summary => ({ total: todos.length, todo: todos.filter(t => !t.done).length, done: todos.filter(t => t.done).length }); @Component({ selector: 'app-todo-summary', standalone: true, imports: [JsonPipe], template: '<pre>{{summary() | json}}</pre>' }) export class TodoSummaryComponent { $t = inject(TodoService); $f = inject(TodoFindService); summary = computed(() => ({ todos: Summary(this.$t.todos()), found: Summary(this.$f.found()) })); }
Notiamo due piccole differenze: una è che ci siamo liberati della AsyncPipe; l’altra è che al posto di combineLatest abbiamo ricavato summary da this.$t.todos() e this.$f.found() in modo sincrono, semplicemente chiamando i getter.
In definitiva, abbiamo visto come i segnali semplifichino di molto le cose quando li usiamo semplicemente al posto di normali variabili per gestire lo stato dei componenti; diventano più macchinosi quando cerchiamo di usarli per gestire gli eventi includendo la variabile tempo: in quel caso passare a RxJS diventa quasi una scelta obbligata.
Oltre alle API viste, Angular al momento fornisce in anteprima un’alternativa ai vari decoratori come @Input per lavorare esclusivamente con i signals. Vedremo come questa nuova API interagirà con le precedenti man mano che il tutto raggiunge una fase di sviluppo più consolidata.
CONTENUTI GRATUITI IN EVIDENZA
Guide per aspiranti programmatori 👨🏻🚀
Vuoi muovere i primi passi nel Digital e Tech? Abbiamo preparato alcune guide per aiutarti a orientarti negli ambiti più richiesti oggi.