Introduzione: la rivoluzione della reattività in Angular
Fin dalle prime versioni di Angular, RxJS è stato il motore della reattività: chiamate HTTP, form, router ed eventi passavano tutti per gli Observable. Uno strumento potentissimo, ma anche impegnativo: per mostrare un semplice valore derivato servivano subject, operatori e sottoscrizioni da chiudere.
Con i Signals il team di Angular ha introdotto un modello più semplice per lo stato dell'applicazione: un valore che si legge, si aggiorna e da cui altri valori dipendono automaticamente. Il risultato è meno codice, una lettura più immediata e le fondamenta per un'Angular senza Zone.js. In questo articolo vediamo come e quando portare lo stato da RxJS ai Signals, con un refactoring completo.
RxJS vs Signals: due strumenti per problemi diversi
La distinzione più utile è questa: RxJS descrive eventi nel tempo — click, messaggi WebSocket, risposte HTTP, digitazioni da rallentare — mentre i Signals descrivono lo stato attuale e i valori che ne derivano. Non sono concorrenti: risolvono problemi diversi, e ne ho parlato più in dettaglio in questo articolo.
| RxJS | Signals | Note |
|---|---|---|
BehaviorSubject<T> | signal<T>() | Valore corrente, leggibile in modo sincrono |
combineLatest() + map() | computed() | Dipendenze tracciate in automatico |
.subscribe() per side effect | effect() | Solo verso il mondo esterno |
async pipe | lettura valore() nel template | Nessuna sottoscrizione da gestire |
debounceTime, switchMap | restano RxJS | Ponte con toObservable |
C'è anche un vantaggio di rilevamento delle modifiche: quando un template legge un signal, Angular sa esattamente quali componenti dipendono da quel valore e aggiorna solo quelli, invece di ricontrollare l'intero albero dei componenti a ogni evento.
Anatomia del refactoring: un caso reale
Lo scenario è classico: una dashboard con l'elenco dei prodotti, un campo di ricerca testuale e il conteggio degli elementi filtrati. Ecco la versione "old-school" con RxJS:
@Component({ /* ... */ })
export class ProductDashboardComponent implements OnInit, OnDestroy {
private readonly products$ = new BehaviorSubject<Product[]>([]);
private readonly filter$ = new BehaviorSubject<string>('');
private readonly destroy$ = new Subject<void>();
visible: Product[] = [];
count = 0;
constructor(private api: ProductApi) {}
ngOnInit(): void {
this.api.list().pipe(takeUntil(this.destroy$))
.subscribe(list => this.products$.next(list));
combineLatest([this.products$, this.filter$.pipe(debounceTime(300))])
.pipe(
map(([list, term]) =>
list.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))),
takeUntil(this.destroy$),
)
.subscribe(visible => {
this.visible = visible;
this.count = visible.length;
});
}
onFilter(term: string): void { this.filter$.next(term); }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
Funziona, ma ha i difetti tipici di questo approccio:
- Molto codice di contorno: due subject, un subject di distruzione, due sottoscrizioni e un
ngOnDestroy. - Flusso difficile da seguire: per capire da dove arriva
countbisogna leggere tutta la catena di operatori. - Rischio di memory leak: basta dimenticare un
takeUntilperché una sottoscrizione resti viva dopo la distruzione del componente. - Stato duplicato:
visibleecountsono copie da tenere sincronizzate a mano.
Passo per passo: lo stato con i Signals
Passo 1: valori e input diventano signal
Ogni BehaviorSubject che rappresenta uno stato diventa un writable signal. Si aggiorna con .set() quando si sostituisce il valore e con .update() quando il nuovo valore dipende dal precedente:
readonly products = signal<Product[]>([]);
readonly filter = signal('');
onFilter(term: string): void {
this.filter.set(term); // replace the value
}
addProduct(p: Product): void {
this.products.update(list => [...list, p]); // derive from the previous one
}
Passo 2: la logica derivata diventa computed
Il combineLatest con map diventa un computed(). Le dipendenze non si dichiarano: Angular le scopre leggendo quali signal vengono usati. In più un computed è pigro e memorizzato: viene ricalcolato solo quando una dipendenza cambia e solo se qualcuno lo legge.
readonly visible = computed(() => {
const term = this.filter().toLowerCase();
return this.products().filter(p => p.name.toLowerCase().includes(term));
});
readonly count = computed(() => this.visible().length);
// Template: no async pipe, no subscriptions
// @for (p of visible(); track p.id) { <app-product-card [product]="p" /> }
// <span>{{ count() }} products</span>
visible e count non sono più copie da sincronizzare: sono sempre coerenti per costruzione.
Passo 3: i side effect, con effect
effect() serve per parlare con il mondo esterno: salvare in localStorage, scrivere un log, sincronizzare una libreria di terze parti come un grafico o una mappa. Non serve, invece, per tenere allineati due signal: in quel caso la risposta è quasi sempre un computed.
constructor() {
// OK: a side effect towards the outside world
effect(() => localStorage.setItem('dashboard.filter', this.filter()));
}
// Anti-pattern: an effect that copies one signal into another
effect(() => this.total.set(this.visible().length));
// Better: derive it, and let Angular keep it in sync
readonly total = computed(() => this.visible().length);
Il ponte tra i due mondi: rxjs-interop
Le chiamate al backend — un'API REST o NestJS — restano naturalmente Observable: HttpClient restituisce stream, ed è lì che servono cancellazione, retry e operatori temporali. Il pacchetto @angular/core/rxjs-interop offre due ponti:
toSignal(): trasforma uno stream, per esempio una risposta HTTP, in un signal da usare direttamente nel template.toObservable(): trasforma un signal in uno stream quando servono operatori temporali comedebounceTime.
Ecco una ricerca in tempo reale che li usa entrambi: il campo di ricerca è un signal, il debounce e la cancellazione delle richieste restano a RxJS, e il risultato torna al template come signal. Entrambe le funzioni vanno chiamate in un contesto di injection, per esempio come inizializzatori di campo.
private readonly api = inject(ProductApi);
readonly query = signal('');
// signal → Observable (time operators) → signal (template)
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] as Product[] },
);
// Template: <input (input)="query.set($any($event.target).value)" />
// @for (p of results(); track p.id) { ... }
Pattern architetturale: uno store basato sui Signals
Quando lo stato serve a più componenti, la logica si sposta in un service. La regola è incapsulare: il signal scrivibile resta privato, all'esterno si espone una versione in sola lettura con .asReadonly(), e le modifiche passano solo dai metodi del service.
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly(); // read-only from outside
readonly total = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.qty, 0));
add(item: CartItem): void { this._items.update(list => [...list, item]); }
clear(): void { this._items.set([]); }
}
Per applicazioni più grandi, Signal Store di @ngrx/signals porta lo stesso modello a livello globale, con una struttura standard per stato, valori derivati e metodi.
Conclusioni e checklist
- Usa i Signals per lo stato sincrono e la UI: valori correnti, filtri, selezioni, conteggi.
- Usa RxJS per gli eventi asincroni e i flussi nel tempo: HTTP, WebSocket, debounce, cancellazione. Gli operatori RxJS restano preziosi.
- Usa
computedper tutto ciò che è derivato, mai uneffectche copia valori. - Fai cooperare i due mondi con rxjs-interop, invece di riscrivere tutto.
- Migra un componente alla volta, partendo da quelli con più sottoscrizioni manuali.
Hai già migrato parte della tua applicazione ai Signals, o hai dubbi su dove tracciare il confine con RxJS? Lascia un commento qui sotto con la tua esperienza: le domande più interessanti diventeranno spunti per i prossimi articoli.
