Tutti gli articoli

Da RxJS a Signals: guida pratica alla migrazione dello stato in Angular

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.

RxJSSignalsNote
BehaviorSubject<T>signal<T>()Valore corrente, leggibile in modo sincrono
combineLatest() + map()computed()Dipendenze tracciate in automatico
.subscribe() per side effecteffect()Solo verso il mondo esterno
async pipelettura valore() nel templateNessuna sottoscrizione da gestire
debounceTime, switchMaprestano RxJSPonte 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 count bisogna leggere tutta la catena di operatori.
  • Rischio di memory leak: basta dimenticare un takeUntil perché una sottoscrizione resti viva dopo la distruzione del componente.
  • Stato duplicato: visible e count sono 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 come debounceTime.

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

  1. Usa i Signals per lo stato sincrono e la UI: valori correnti, filtri, selezioni, conteggi.
  2. Usa RxJS per gli eventi asincroni e i flussi nel tempo: HTTP, WebSocket, debounce, cancellazione. Gli operatori RxJS restano preziosi.
  3. Usa computed per tutto ciò che è derivato, mai un effect che copia valori.
  4. Fai cooperare i due mondi con rxjs-interop, invece di riscrivere tutto.
  5. 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.

💬 Note dei lettori

0 note

Scrivi una nota

Condividi la tua opinione, un suggerimento o un complimento

Ultime note

Nessuna nota ancora. Sii il primo a commentare!