Angular Signals: Il Futuro della Reattività in Angular
Con Angular 16 il team ha introdotto una delle innovazioni più significative nella storia del framework: i Signals. Questa primitiva reattiva rappresenta un cambio di paradigma fondamentale, spostando Angular da un sistema di change detection basato su Zone.js a un modello di reattività fine-grained, dove solo i componenti effettivamente interessati da un cambiamento di stato vengono aggiornati.
In questo primo articolo della serie Angular Moderno esploreremo i Signals in profondità: dalla teoria alla pratica, dalla creazione di semplici stati reattivi fino a pattern avanzati pronti per la produzione. Se hai già familiarita con RxJS e Zone.js, scoprirai come i Signals semplificano drasticamente la gestione dello stato mantenendo prestazioni eccellenti.
Panoramica della Serie Angular Moderno
| # | Articolo | Focus |
|---|---|---|
| 1 | Sei qui - Angular Signals | Reattività fine-grained |
| 2 | Zoneless Change Detection | Eliminare Zone.js |
| 3 | Nuovi Template @if, @for, @defer | Control flow moderno |
| 4 | Standalone Components | Architettura senza NgModule |
| 5 | Signal Forms | Form reattivi con Signals |
| 6 | SSR e Incremental Hydration | Server-Side Rendering |
| 7 | Core Web Vitals in Angular | Performance e metriche |
| 8 | Angular PWA | Progressive Web App |
| 9 | Dependency Injection Avanzata | DI tree-shakable |
| 10 | Migrare da Angular 17 a 21 | Guida alla migrazione |
perchè i Signals? Il Problema di Zone.js
Per comprendere perchè i Signals sono stati introdotti, bisogna prima capire il problema che risolvono. Dalla sua nascita, Angular ha utilizzato Zone.js per intercettare eventi asincroni (click, timer, HTTP request) e triggherare automaticamente il ciclo di change detection. Questo approccio, seppur comodo, presenta limiti significativi:
Limiti di Zone.js
- Overhead globale: Zone.js patcha TUTTE le API asincrone del browser (setTimeout, Promise, addEventListener), aggiungendo peso al bundle (~13KB minificato)
- Change detection troppo ampia: Ogni evento asincrono scatena un check dell'intero albero dei componenti, anche se lo stato non è cambiato
- Incompatibilita: Alcune librerie di terze parti non funzionano correttamente con Zone.js (Web Components, alcune librerie native)
- Debugging complesso: Gli stack trace sono inquinati dai frame di Zone.js, rendendo difficile il debugging
- Scalabilità: In applicazioni con centinaia di componenti, la change detection globale diventa un collo di bottiglia
I Signals risolvono questi problemi introducendo un sistema di reattività basato su grafo: ogni Signal sa esattamente quali consumatori dipendono da esso, e quando il suo valore cambia, solo quei consumatori vengono notificati e aggiornati. Nessun polling, nessun check globale.
Signals vs Zone.js: Il Confronto
| Aspetto | Zone.js | Signals |
|---|---|---|
| Granularità | Intero albero componenti | Singolo binding nel template |
| Trigger | Qualsiasi evento asincrono | Solo quando il valore cambia |
| Bundle size | +13KB (zone.js) | 0KB aggiuntivi |
| Tracking | Implicito (monkey-patching) | Esplicito (dependency graph) |
| Debugging | Stack trace inquinati | Stack trace puliti |
| Lazy evaluation | No | Si (computed) |
Anatomia di un Signal
Un Signal è un wrapper reattivo attorno a un valore. Puoi pensarlo come una
variabile che notifica automaticamente i suoi consumatori quando il valore cambia. La funzione
signal() crea un WritableSignal, ovvero un Signal che può essere
letto e scritto.
Creare e Leggere un Signal
import { signal } from '@angular/core';
// Creazione di un WritableSignal con valore iniziale
const count = signal(0); // WritableSignal<number>
const name = signal('Angular'); // WritableSignal<string>
const items = signal<string[]>([]); // Tipo esplicito con generics
// LEGGERE il valore: chiama il signal come funzione
console.log(count()); // 0
console.log(name()); // 'Angular'
console.log(items()); // []
// SCRIVERE un nuovo valore: usa .set()
count.set(5);
console.log(count()); // 5
// AGGIORNARE basandosi sul valore corrente: usa .update()
count.update(current => current + 1);
console.log(count()); // 6
// AGGIORNARE oggetti/array: usa .update() per immutabilita
items.update(list => [...list, 'Nuovo elemento']);
console.log(items()); // ['Nuovo elemento']
perchè si usa la chiamata a funzione per leggere?
La sintassi count() non è solo una scelta stilistica: è il meccanismo che
permette ad Angular di tracciare automaticamente le dipendenze. Quando
leggi un Signal dentro un computed() o un effect(), Angular
registra quella lettura come dipendenza. Se usassi una proprietà semplice come
count.value, questo tracking automatico non sarebbe possibile.
WritableSignal vs ReadonlySignal
Angular distingue tra due tipi di Signal. Un WritableSignal espone i metodi
.set() e .update(), mentre un Signal (ReadonlySignal)
permette solo la lettura. Questa distinzione è fondamentale per l'incapsulamento.
import { signal, Signal, WritableSignal } from '@angular/core';
// Signal privato e scrivibile nel servizio
private _counter: WritableSignal<number> = signal(0);
// Esponi solo la versione read-only ai consumatori
readonly counter: Signal<number> = this._counter.asReadonly();
// Metodo pubblico controllato per modificare lo stato
increment(): void {
this._counter.update(v => v + 1);
}
// I consumatori possono leggere ma NON scrivere
// this.counterService.counter() // OK - lettura
// this.counterService.counter.set(5) // ERRORE - non esiste .set()
Uguaglianza e Notifiche
Per default, un Signal notifica i consumatori solo quando il valore cambia secondo
Object.is(). Puoi personalizzare il confronto con l'opzione equal:
// Signal con confronto personalizzato
const user = signal(
{ name: 'Federico', age: 30 },
{
equal: (a, b) => a.name === b.name && a.age === b.age
}
);
// Questo NON notifica i consumatori (stesso contenuto)
user.set({ name: 'Federico', age: 30 });
// Questo notifica i consumatori (contenuto diverso)
user.set({ name: 'Federico', age: 31 });
Computed Signals: Stato Derivato
La funzione computed() crea un Signal il cui valore e derivato automaticamente
da altri Signals. È l'equivalente reattivo di una proprietà calcolata: il valore viene
ricalcolato solo quando una delle sue dipendenze cambia, e il risultato è memoizzato
(cached) fino al prossimo aggiornamento.
import { signal, computed } from '@angular/core';
const firstName = signal('Federico');
const lastName = signal('Calo');
const age = signal(30);
// Computed: ricalcolato SOLO quando firstName o lastName cambiano
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // 'Federico Calo'
// Computed con logica condizionale
const canVote = computed(() => age() >= 18);
console.log(canVote()); // true
// Computed che dipende da altri computed (catena)
const greeting = computed(() =>
`Ciao ${fullName()}, ${canVote() ? 'puoi votare' : 'non puoi votare'}`
);
console.log(greeting()); // 'Ciao Federico Calo, puoi votare'
// Quando cambia firstName, fullName e greeting si aggiornano
// ma canVote NO (non dipende da firstName)
firstName.set('Marco');
console.log(fullName()); // 'Marco Calo'
console.log(greeting()); // 'Ciao Marco Calo, puoi votare'
Caratteristiche Chiave di computed()
- Lazy evaluation: Il valore viene calcolato solo quando qualcuno lo legge, non quando le dipendenze cambiano
- Memoizzazione: Se nessuna dipendenza è cambiata, la lettura restituisce il valore cached senza ricalcolo
- Tracking automatico: Le dipendenze vengono scoperte durante l'esecuzione della funzione di derivazione
- Dipendenze dinamiche: Se la logica include un
if, vengono tracciate solo le dipendenze effettivamente lette in quel passaggio - Read-only: Un computed Signal non ha metodi
.set()o.update()
Attenzione: No Side Effects nei computed()
La funzione passata a computed() deve essere pura: non deve
modificare altri Signals, effettuare chiamate HTTP, scrivere nel DOM o eseguire operazioni
con effetti collaterali. Angular può rieseguire la funzione in qualsiasi momento per
verificare se il valore è cambiato. Per i side effects, usa effect().
Effects: Effetti Collaterali Reattivi
La funzione effect() esegue un side effect ogni volta che i Signals letti
al suo interno cambiano valore. È il meccanismo ideale per sincronizzare lo stato dei
Signals con il mondo esterno: logging, localStorage, analytics, DOM manipulation.
import { signal, effect, Component } from '@angular/core';
@Component({
selector: 'app-logger',
standalone: true,
template: `<button (click)="increment()">Count: {{ count() }}</button>`
})
export class LoggerComponent {
count = signal(0);
constructor() {
// L'effect viene eseguito immediatamente e poi ogni volta
// che count() cambia valore
effect(() => {
console.log(`Il contatore vale: ${this.count()}`);
// Output: "Il contatore vale: 0" (subito)
// Output: "Il contatore vale: 1" (dopo click)
});
}
increment() {
this.count.update(v => v + 1);
}
}
Cleanup negli Effects
Quando un effect viene rieseguito o distrutto, potresti aver bisogno di pulire risorse
come timer, sottoscrizioni o listener. Angular fornisce una callback onCleanup
per questo scopo:
import { signal, effect } from '@angular/core';
const pollingInterval = signal(5000);
effect((onCleanup) => {
const interval = pollingInterval();
const timerId = setInterval(() => {
console.log('Polling dati dal server...');
}, interval);
// Questa funzione viene chiamata PRIMA della prossima esecuzione
// e quando il componente viene distrutto
onCleanup(() => {
clearInterval(timerId);
console.log('Timer pulito');
});
});
Injection Context per gli Effects
effect() deve essere chiamato all'interno di un injection context,
ovvero nel costruttore di un componente, direttiva o servizio, oppure all'interno di
una funzione factory con inject(). Se hai bisogno di creare un effect fuori
dal costruttore, passa esplicitamente l'Injector:
import { signal, effect, inject, Injector, Component } from '@angular/core';
@Component({
selector: 'app-deferred',
standalone: true,
template: `<button (click)="startTracking()">Avvia tracking</button>`
})
export class DeferredEffectComponent {
private injector = inject(Injector);
data = signal('');
startTracking() {
// Passiamo l'injector esplicitamente
effect(() => {
console.log('Dati aggiornati:', this.data());
}, { injector: this.injector });
}
}
Signal Inputs: Il Nuovo @Input()
A partire da Angular 17.1, il decoratore @Input() ha un'alternativa moderna basata
su Signals: la funzione input(). I Signal Inputs sono read-only
e si integrano perfettamente nel grafo reattivo, permettendo di usarli direttamente con
computed() e effect().
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
template: `
<div class="card">
<h3>{{ displayName() }}</h3>
<span class="badge" [class.active]="isActive()">
{{ isActive() ? 'Attivo' : 'Inattivo' }}
</span>
</div>
`
})
export class UserCardComponent {
// Input opzionale con valore di default
name = input('Anonimo'); // InputSignal<string>
// Input obbligatorio (il parent DEVE fornirlo)
userId = input.required<number>(); // InputSignal<number>
// Input con alias (nome diverso nel template del parent)
active = input(false, { alias: 'isUserActive' });
// Computed che dipendono dagli input - tutto reattivo!
displayName = computed(() =>
`${this.name()} (ID: ${this.userId()})`
);
isActive = computed(() => this.active());
}
// Uso nel parent:
// <app-user-card [name]="'Federico'" [userId]="42" [isUserActive]="true" />
Vantaggi di input() su @Input()
- Type-safe:
input.required()genera un errore di compilazione se il parent non fornisce il valore - Reattività nativa: Il valore è un Signal, quindi funziona direttamente con
computed()eeffect() - Niente OnChanges: Non serve più implementare
ngOnChangesper reagire ai cambiamenti degli input - Transform integrato: Puoi trasformare il valore in ingresso con l'opzione
transform - Immutabilita: Gli input Signal sono read-only, prevenendo modifiche accidentali nel componente figlio
Transform degli Input
import { Component, input, booleanAttribute, numberAttribute } from '@angular/core';
@Component({
selector: 'app-settings',
standalone: true,
template: `
<p>Colonne: {{ columns() }}</p>
<p>Disabilitato: {{ disabled() }}</p>
`
})
export class SettingsComponent {
// Converte automaticamente la stringa "3" nel numero 3
columns = input(1, { transform: numberAttribute });
// Converte la presenza dell'attributo in boolean
// <app-settings disabled /> => true
disabled = input(false, { transform: booleanAttribute });
}
Signal Outputs: Il Nuovo @Output()
Analogamente agli input, Angular introduce output() come alternativa moderna
al decoratore @Output(). La funzione output() restituisce un
OutputEmitterRef con un metodo .emit() per emettere eventi.
import { Component, input, output } from '@angular/core';
@Component({
selector: 'app-todo-item',
standalone: true,
template: `
<div class="todo">
<span>{{ text() }}</span>
<button (click)="onComplete()">Completa</button>
<button (click)="onDelete()">Elimina</button>
</div>
`
})
export class TodoItemComponent {
// Input
text = input.required<string>();
todoId = input.required<number>();
// Output - tipizzati e type-safe
completed = output<number>(); // OutputEmitterRef<number>
deleted = output<number>(); // OutputEmitterRef<number>
onComplete() {
this.completed.emit(this.todoId());
}
onDelete() {
this.deleted.emit(this.todoId());
}
}
// Uso nel parent:
// <app-todo-item
// [text]="'Studiare Signals'"
// [todoId]="1"
// (completed)="handleComplete($event)"
// (deleted)="handleDelete($event)"
// />
Model Signals: Two-Way Binding
La funzione model() crea un Signal speciale che supporta il
two-way binding tra componente parent e child. È l'evoluzione moderna
della combinazione @Input() + @Output() con la convenzione
valueChange.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-rating',
standalone: true,
template: `
<div class="stars">
@for (star of stars; track star) {
<span
(click)="value.set(star)"
[class.filled]="star <= value()">
★
</span>
}
</div>
`
})
export class RatingComponent {
// model() crea un WritableSignal con two-way binding
value = model(0); // ModelSignal<number>
stars = [1, 2, 3, 4, 5];
}
// Uso nel parent con banana-in-a-box syntax:
// <app-rating [(value)]="userRating" />
// Quando il child chiama value.set(3), userRating nel parent si aggiorna
Come funziona model() internamente
model() crea automaticamente sia un input che un output.
Per un model chiamato value, Angular crea implicitamente
un input value e un output valueChange. Quando chiami
value.set() o value.update(), il componente emette
automaticamente l'evento valueChange, propagando la modifica al parent.
Signal Queries: viewChild() e viewChildren()
I decoratori @ViewChild() e @ViewChildren() hanno ora
le loro controparti basate su Signal: viewChild() e viewChildren().
Queste funzioni restituiscono Signal che si aggiornano automaticamente quando gli
elementi referenziati cambiano.
import {
Component, viewChild, viewChildren,
ElementRef, AfterViewInit, effect
} from '@angular/core';
@Component({
selector: 'app-gallery',
standalone: true,
template: `
<input #searchInput type="text" placeholder="Cerca..." />
<div class="gallery">
@for (img of images(); track img.id) {
<img #galleryItem [src]="img.url" [alt]="img.title" />
}
</div>
`
})
export class GalleryComponent {
images = signal([
{ id: 1, url: '/img1.jpg', title: 'Foto 1' },
{ id: 2, url: '/img2.jpg', title: 'Foto 2' },
]);
// viewChild restituisce un Signal<ElementRef | undefined>
searchInput = viewChild<ElementRef>('searchInput');
// viewChild.required restituisce Signal<ElementRef> (no undefined)
// requiredInput = viewChild.required<ElementRef>('searchInput');
// viewChildren restituisce Signal<readonly ElementRef[]>
galleryItems = viewChildren<ElementRef>('galleryItem');
constructor() {
// Reagisci quando gli elementi della gallery cambiano
effect(() => {
const items = this.galleryItems();
console.log(`Gallery ha ${items.length} elementi`);
});
// Focus automatico sull'input di ricerca
effect(() => {
const input = this.searchInput();
if (input) {
input.nativeElement.focus();
}
});
}
}
Vantaggi delle Signal Queries
- Niente AfterViewInit: Non serve più il lifecycle hook; l'effect reagisce automaticamente quando il riferimento è disponibile
- Aggiornamento reattivo: Se la lista di elementi cambia (es. con
@for), il SignalviewChildrensi aggiorna - Type safety:
viewChild.required()garantisce che il riferimento esiste, eliminando i checkundefined - Composabilità: Puoi usare
computed()per derivare dati dalle query
Pattern Pratici per la Produzione
Dopo aver visto le singole API, esploriamo pattern concreti che userai quotidianamente nelle applicazioni Angular in produzione.
Pattern 1: Stato del Form con Signals
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-search',
standalone: true,
template: `
<input
[value]="query()"
(input)="onSearch($event)"
placeholder="Cerca prodotti..." />
<div class="filters">
<select (change)="onCategoryChange($event)">
<option value="">Tutte le categorie</option>
<option value="electronics">Elettronica</option>
<option value="books">Libri</option>
</select>
</div>
<p>Trovati {{ filteredProducts().length }} risultati</p>
@for (product of filteredProducts(); track product.id) {
<div class="product-card">
<h4>{{ product.name }}</h4>
<span>{{ product.price | currency:'EUR' }}</span>
</div>
}
`
})
export class SearchComponent {
// Stato reattivo del form
query = signal('');
category = signal('');
products = signal<Product[]>([]);
// Stato derivato: filtraggio reattivo
filteredProducts = computed(() => {
const q = this.query().toLowerCase();
const cat = this.category();
return this.products().filter(p =>
p.name.toLowerCase().includes(q) &&
(!cat || p.category === cat)
);
});
onSearch(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.query.set(value);
}
onCategoryChange(event: Event) {
const value = (event.target as HTMLSelectElement).value;
this.category.set(value);
}
}
interface Product {
id: number;
name: string;
price: number;
category: string;
}
Pattern 2: Stato Condiviso con un Service
import { Injectable, signal, computed } from '@angular/core';
interface CartItem {
productId: number;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartService {
// Stato privato
private _items = signal<CartItem[]>([]);
private _discount = signal(0);
// Stato pubblico read-only
readonly items = this._items.asReadonly();
readonly discount = this._discount.asReadonly();
// Stato derivato
readonly itemCount = computed(() =>
this._items().reduce((sum, item) => sum + item.quantity, 0)
);
readonly subtotal = computed(() =>
this._items().reduce((sum, item) => sum + item.price * item.quantity, 0)
);
readonly total = computed(() => {
const sub = this.subtotal();
const disc = this._discount();
return sub - (sub * disc / 100);
});
readonly isEmpty = computed(() => this._items().length === 0);
// Azioni pubbliche
addItem(product: Omit<CartItem, 'quantity'>) {
this._items.update(items => {
const existing = items.find(i => i.productId === product.productId);
if (existing) {
return items.map(i =>
i.productId === product.productId
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { ...product, quantity: 1 }];
});
}
removeItem(productId: number) {
this._items.update(items =>
items.filter(i => i.productId !== productId)
);
}
applyDiscount(percent: number) {
this._discount.set(Math.min(percent, 50)); // Max 50%
}
clearCart() {
this._items.set([]);
this._discount.set(0);
}
}
perchè questo pattern funziona bene
Questo service può essere iniettato in qualsiasi componente. Ogni componente che legge
cartService.total() nel suo template verrà aggiornato automaticamente
quando il carrello cambia. Non servono sottoscrizioni manuali, non serve
async pipe, non servono Subject o BehaviorSubject.
È stato puro e reattivo, con incapsulamento garantito da asReadonly().
Pattern 3: Caricamento Dati da API
import { Component, signal, computed, inject, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
@Component({
selector: 'app-users-list',
standalone: true,
template: `
@if (state().loading) {
<div class="spinner">Caricamento...</div>
}
@if (state().error; as error) {
<div class="error">Errore: {{ error }}</div>
}
@if (state().data; as users) {
<ul>
@for (user of users; track user.id) {
<li>{{ user.name }} - {{ user.email }}</li>
}
</ul>
}
`
})
export class UsersListComponent implements OnInit {
private http = inject(HttpClient);
state = signal<ApiState<User[]>>({
data: null,
loading: false,
error: null
});
// Computed di convenienza
isLoading = computed(() => this.state().loading);
hasError = computed(() => this.state().error !== null);
ngOnInit() {
this.loadUsers();
}
loadUsers() {
// Imposta loading
this.state.set({ data: null, loading: true, error: null });
this.http.get<User[]>('/api/users').subscribe({
next: (users) => {
this.state.set({ data: users, loading: false, error: null });
},
error: (err) => {
this.state.set({
data: null,
loading: false,
error: err.message || 'Errore sconosciuto'
});
}
});
}
}
interface User {
id: number;
name: string;
email: string;
}
Interoperabilità con RxJS: toSignal() e toObservable()
Angular non abbandona RxJS: i due mondi coesistono e collaborano grazie alle funzioni
di interoperabilità nel pacchetto @angular/core/rxjs-interop.
Queste funzioni sono il ponte tra il mondo imperativo dei Signals e quello dichiarativo
degli Observable.
toSignal(): Da Observable a Signal
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { interval } from 'rxjs';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<p>Utenti: {{ users()?.length ?? 'Caricamento...' }}</p>
<p>Timer: {{ timer() }} secondi</p>
`
})
export class DashboardComponent {
private http = inject(HttpClient);
// Converti un HTTP Observable in Signal
// Il Signal sarà undefined finchè la risposta non arriva
users = toSignal(this.http.get<User[]>('/api/users'));
// Con valore iniziale (evita undefined)
timer = toSignal(interval(1000), { initialValue: 0 });
// Con requireSync per Observable che emettono subito (es. BehaviorSubject)
// config = toSignal(this.configService.config$, { requireSync: true });
}
toObservable(): Da Signal a Observable
import { Component, signal, inject, effect } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-autocomplete',
standalone: true,
template: `
<input
[value]="searchTerm()"
(input)="onInput($event)"
placeholder="Cerca..." />
@for (result of results(); track result.id) {
<div>{{ result.name }}</div>
}
`
})
export class AutocompleteComponent {
private http = inject(HttpClient);
searchTerm = signal('');
// Converti Signal in Observable, applica operatori RxJS,
// poi riconverti in Signal
results = toSignal(
toObservable(this.searchTerm).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(term =>
term.length > 2
? this.http.get<SearchResult[]>(`/api/search?q=${term}`)
: []
)
),
{ initialValue: [] as SearchResult[] }
);
onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.searchTerm.set(value);
}
}
interface SearchResult {
id: number;
name: string;
}
Quando usare toSignal() vs toObservable()
| Scenario | Usa | Motivo |
|---|---|---|
| HTTP nel template | toSignal() |
Sostituisce async pipe, più pulito |
| Debounce/throttle | toObservable() + RxJS |
Gli operatori RxJS sono ideali per flussi temporali |
| Stato locale UI | signal() diretto |
Non serve RxJS per toggle, contatori, form |
| WebSocket/SSE | toSignal() |
Converti lo stream in Signal per il template |
| Combinare multipli Signal | computed() |
Più semplice di combineLatest |
Migrazione da Zone.js: Guida Pratica
Migrare un'applicazione esistente ai Signals non richiede una riscrittura totale. Puoi adottare un approccio incrementale, convertendo un componente alla volta. Ecco una strategia pratica.
Step 1: Converti le proprietà semplici in Signals
// PRIMA (Zone.js)
@Component({ ... })
export class CounterComponent {
count = 0;
label = 'Contatore';
increment() {
this.count++;
}
}
// Template: {{ count }} - {{ label }}
// DOPO (Signals)
@Component({ ... })
export class CounterComponent {
count = signal(0);
label = signal('Contatore');
increment() {
this.count.update(v => v + 1);
}
}
// Template: {{ count() }} - {{ label() }}
Step 2: Converti i getter in computed()
// PRIMA
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}
get isValid(): boolean {
return this.email.includes('@') && this.name.length > 0;
}
// DOPO
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
isValid = computed(() =>
this.email().includes('@') && this.name().length > 0
);
Step 3: Converti le sottoscrizioni in effects o toSignal()
// PRIMA (sottoscrizione manuale)
@Component({ ... })
export class UserProfileComponent implements OnInit, OnDestroy {
user: User | null = null;
private destroy$ = new Subject<void>();
constructor(private userService: UserService) {}
ngOnInit() {
this.userService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => this.user = user);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
// DOPO (con toSignal - zero boilerplate)
@Component({ ... })
export class UserProfileComponent {
private userService = inject(UserService);
// Si sottoscrive automaticamente, si pulisce automaticamente
user = toSignal(this.userService.currentUser$);
}
Attenzione durante la Migrazione
- Aggiorna i template: Ogni proprietà convertita in Signal richiede
()nel template:{{ count }}diventa{{ count() }} - toSignal() richiede injection context: Deve essere chiamato nel costruttore o in un campo inizializzato a livello di classe
- Non mescolare nello stesso componente: Evita di avere meta proprietà come Signal e meta come variabili semplici: conviene migrare tutto il componente in un colpo
- Test: I test devono essere aggiornati per leggere
component.count()invece dicomponent.count
Best Practices e Errori Comuni
Dopo mesi di utilizzo in produzione, la community Angular ha consolidato una serie di best practices. Ecco le più importanti.
Le 8 Regole d'Oro dei Signals
- Usa
signal()per stato locale - Toggle, contatori, form state: preferisci sempre Signal a variabili semplici - Usa
computed()per stato derivato - Mai duplicare stato che può essere calcolato. Se dipende da altri Signals, e un computed - Usa
effect()con parsimonia - Solo per side effects reali (logging, localStorage, analytics). Non usarlo per sincronizzare Signals tra loro - Esponi
asReadonly()- Nei service, rendi il Signal interno privato e writable, ed esponi solo la versione read-only - Preferisci
input()a @Input() - Gli input Signal sono più sicuri, reattivi e type-safe - Usa
computed()invece dieffect()per derivare stato - Se il risultato e un valore derivato, usa computed. Effect e per azioni, non per calcoli - Non modificare Signals dentro
computed()- La funzione deve essere pura. Scrivere su un Signal dentro computed causa comportamenti imprevedibili - Evita la ricorsione - Un effect che scrive su un Signal che l'effect stesso legge crea un loop infinito
Anti-pattern da Evitare
// ERRORE 1: Modificare un Signal dentro computed()
const a = signal(1);
const b = signal(0);
const sum = computed(() => {
b.set(a() * 2); // MAI! Side effect in computed
return a() + b();
});
// ERRORE 2: Dimenticare le parentesi nel template
// <p>{{ count }}</p> <-- Mostra la funzione, non il valore!
// <p>{{ count() }}</p> <-- Corretto
// ERRORE 3: Mutare l'oggetto invece di crearne uno nuovo
const user = signal({ name: 'Fed', age: 30 });
// NON fare questo:
user().name = 'Marco'; // La mutation non viene rilevata!
// Fai invece:
user.update(u => ({ ...u, name: 'Marco' }));
// ERRORE 4: Loop infinito con effect
const counter = signal(0);
effect(() => {
// Legge e scrive lo stesso Signal = loop!
counter.set(counter() + 1);
});
// ERRORE 5: toSignal() fuori dall'injection context
@Component({ ... })
export class BadComponent {
// Questo funziona (campo di classe)
data = toSignal(this.http.get('/api/data'));
onClick() {
// Questo NON funziona (metodo, no injection context)
const result = toSignal(this.http.get('/api/other'));
}
}
Quando NON usare i Signals
RxJS resta la scelta migliore per:
- Stream complessi: WebSocket, Server-Sent Events, polling con retry
- Operatori temporali: debounceTime, throttleTime, delay, bufferTime
- Flussi condizionali: switchMap, exhaustMap, mergeMap con logica complessa
- Race conditions: Quando serve cancellare richieste precedenti (switchMap)
- Backpressure: Gestione di stream ad alta frequenza
La regola pratica: se il tuo problema riguarda il tempo o la concorrenza, usa RxJS. Se riguarda lo stato, usa Signals.
Riepilogo e Prossimi Passi
In questo articolo abbiamo esplorato in profondità il sistema di Signals di Angular: dalla motivazione alla sintassi, dai pattern pratici alla migrazione. Ecco i punti chiave:
Concetti Chiave
signal()crea stato reattivo con.set()e.update()computed()deriva stato in modo lazy e memoizzatoeffect()esegue side effects reattivi con cleanup automaticoinput(),output()emodel()sostituiscono i decoratori classiciviewChild()eviewChildren()rendono le query reattivetoSignal()etoObservable()collegano il mondo RxJS e SignalsasReadonly()garantisce incapsulamento nei service
Nel prossimo articolo della serie, Zoneless Change Detection, vedremo come eliminare completamente Zone.js dall'applicazione, sfruttando i Signals come unico meccanismo di reattività e ottenendo miglioramenti significativi nelle performance e nella dimensione del bundle.
Domande frequenti
Come funziona il sistema di reattività dei Signals in Angular?
Il sistema di reattività dei Signals in Angular rappresenta un cambio di paradigma fondamentale, spostando Angular da un sistema di change detection basato su Zone.js a un modello di reattività fine-grained.
Perché i Signals sono stati introdotti?
I Signals sono stati introdotti per risolvere il problema dei limiti significativi del sistema di change detection basato su Zone.js, come ad esempio la necessità di polling e check globale.
Qual è la differenza tra WritableSignal e ReadonlySignal in Angular?
La principale differenza tra WritableSignal e ReadonlySignal in Angular è che il primo permette sia la lettura che la scrittura, mentre il secondo permette solo la lettura. Questa distinzione è fondamentale per l'incapsulamento.
Come funziona la funzione toObservable() per convertire un Signal in un Observable?
La funzione toObservable() restituisce un Observable che emette il valore del Signal ogni volta che cambia. Questo consente di utilizzare i Signals con le librerie di RxJS.

Commenti
Caricamento commenti...
Accedi per commentare