Questo articolo spiega la differenza tra Angular Signals e RxJS, quando usare l'uno, quando usare l'altro e come combinarli in applicazioni Angular reali.
Angular Signals e RxJS non sono due strumenti in guerra tra loro. Sono due strumenti diversi, nati per risolvere problemi diversi, e nelle applicazioni Angular moderne spesso lavorano molto bene insieme.
Il problema nasce quando li confrontiamo come se fossero intercambiabili al cento per cento. In realtà, i Signals sono perfetti per rappresentare uno stato corrente, leggibile e derivabile in modo sincrono. RxJS, invece, è perfetto per modellare flussi asincroni nel tempo, eventi, cancellazioni, debounce, retry, WebSocket e combinazioni complesse di stream.
La domanda corretta quindi non è: “Signals o RxJS?”. La domanda corretta è: “Sto gestendo uno stato attuale o sto gestendo un flusso di eventi nel tempo?”.
Il modello mentale: valore attuale vs flusso nel tempo
Un Signal è come una variabile reattiva. Contiene un valore attuale. Quando quel valore cambia, Angular sa quali parti dell'interfaccia devono essere aggiornate.
Un Observable RxJS è una sequenza di valori nel tempo. Può emettere zero, uno o molti valori. Può completare, può generare errore, può essere cancellato tramite unsubscribe e può essere trasformato con operatori come map, filter, switchMap, debounceTime, retry e molti altri.
| Scenario | Signals | RxJS |
|---|---|---|
| Stato UI locale | Ottimo | Possibile, ma spesso eccessivo |
| Valori derivati | Ottimo con computed |
Possibile con operatori |
| HTTP singola chiamata | Utile per salvare il risultato | Ottimo perché HttpClient restituisce Observable |
| Debounce ricerca | Da combinare con RxJS | Ottimo |
| WebSocket | Utile per mostrare ultimo stato | Ottimo per lo stream |
| Cancellazione richieste | Non è il suo caso migliore | Ottimo con switchMap |
| Template Angular | Molto leggibile | Molto valido con async pipe |
Che cosa sono gli Angular Signals
I Signals sono un sistema di reattività integrato in Angular. Un signal contiene un valore e permette ad Angular di tracciare automaticamente chi legge quel valore.
Quando un componente legge un signal nel template, Angular può aggiornare quella parte della UI quando il signal cambia. Questo rende lo stato più esplicito e riduce la necessità di molti pattern basati su Subject o BehaviorSubject per casi semplici.
import { computed, signal } from '@angular/core';
const count = signal(0);
const double = computed(() => count() * 2);
function increment(): void {
count.update((value) => value + 1);
}
In questo esempio count è lo stato principale, mentre double è uno stato derivato. Non dobbiamo aggiornare manualmente double: Angular lo ricalcola quando cambia count.
Quando i Signals sono ideali
- Quando devi gestire stato locale di un componente.
- Quando devi mostrare o nascondere parti della UI.
- Quando devi gestire filtri locali.
- Quando devi calcolare valori derivati.
- Quando vuoi evitare subscription manuali inutili.
- Quando vuoi creare uno store semplice per una feature.
- Quando lo stato è sincrono e rappresenta sempre “il valore attuale”.
Che cosa è RxJS in Angular
RxJS è una libreria per la programmazione reattiva basata su Observable. In Angular è usata da molti anni ed è ancora centrale in tante API del framework.
HttpClient restituisce Observable. Le reactive forms espongono valueChanges e statusChanges. Il router espone parametri, query params ed eventi come Observable. Anche WebSocket, timer, eventi DOM e stream complessi si modellano molto bene con RxJS.
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => this.api.searchProducts(query))
);
Questo esempio è tipico: l'utente scrive in un campo di ricerca, aspettiamo 300 millisecondi, ignoriamo valori uguali e cancelliamo automaticamente la richiesta precedente se arriva una nuova ricerca.
Quando RxJS è ideale
- Quando hai eventi asincroni nel tempo.
- Quando devi fare debounce o throttle.
- Quando devi cancellare richieste precedenti.
- Quando devi combinare più sorgenti asincrone.
- Quando lavori con WebSocket o stream live.
- Quando devi gestire retry, timeout o polling.
- Quando devi orchestrare flussi complessi.
Regola pratica semplice
Usa Signals per rappresentare lo stato corrente della UI.
Usa computed per valori derivati dallo stato.
Usa RxJS per eventi asincroni, stream nel tempo e logica di cancellazione.
Usa toSignal quando vuoi esporre un Observable come stato leggibile nel template.
Usa toObservable quando un signal deve entrare in una pipeline RxJS.
Esempio reale 1: lista prodotti con filtri locali
Immaginiamo una pagina e-commerce con una lista di prodotti già caricati. L'utente può filtrare per testo, categoria e disponibilità. In questo caso i Signals sono una scelta molto naturale.
Lo stato principale è composto da prodotti, query, categoria selezionata e flag “solo disponibili”. I prodotti filtrati sono uno stato derivato.
import { computed, inject, Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
export interface Product {
id: number;
name: string;
category: string;
price: number;
stock: number;
}
@Injectable()
export class ProductsStore {
private readonly api = inject(ProductsApiService);
private readonly _products = signal<Product[]>([]);
private readonly _loading = signal(false);
private readonly _error = signal<string | null>(null);
private readonly _query = signal('');
private readonly _category = signal<string | null>(null);
private readonly _onlyAvailable = signal(false);
readonly products = this._products.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
readonly query = this._query.asReadonly();
readonly category = this._category.asReadonly();
readonly onlyAvailable = this._onlyAvailable.asReadonly();
readonly filteredProducts = computed(() => {
const query = this._query().toLowerCase().trim();
const category = this._category();
const onlyAvailable = this._onlyAvailable();
return this._products().filter((product) => {
const matchesQuery = product.name.toLowerCase().includes(query);
const matchesCategory = !category || product.category === category;
const matchesAvailability = !onlyAvailable || product.stock > 0;
return matchesQuery && matchesCategory && matchesAvailability;
});
});
readonly total = computed(() => this.filteredProducts().length);
async loadProducts(): Promise<void> {
this._loading.set(true);
this._error.set(null);
try {
const products = await firstValueFrom(this.api.getProducts());
this._products.set(products);
} catch {
this._error.set('Impossibile caricare i prodotti.');
} finally {
this._loading.set(false);
}
}
setQuery(query: string): void {
this._query.set(query);
}
setCategory(category: string | null): void {
this._category.set(category);
}
setOnlyAvailable(value: boolean): void {
this._onlyAvailable.set(value);
}
}
Questo store è semplice da leggere. I dati principali sono signal. I dati derivati sono computed. Il componente può limitarsi a mostrare lo stato.
import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core';
@Component({
selector: 'app-products-page',
standalone: true,
template: `
<section class="products-page">
<h2>Prodotti</h2>
<input
type="search"
placeholder="Cerca prodotto..."
[value]="store.query()"
(input)="store.setQuery($any($event.target).value)"
/>
<label>
<input
type="checkbox"
[checked]="store.onlyAvailable()"
(change)="store.setOnlyAvailable($any($event.target).checked)"
/>
Solo disponibili
</label>
@if (store.loading()) {
<p>Caricamento...</p>
} @else if (store.error()) {
<p class="error">{{ store.error() }}</p>
} @else {
<p>Risultati: {{ store.total() }}</p>
@for (product of store.filteredProducts(); track product.id) {
<article class="product-card">
<h3>{{ product.name }}</h3>
<p>Categoria: {{ product.category }}</p>
<p>Prezzo: {{ product.price }} €</p>
</article>
}
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductsPageComponent implements OnInit {
readonly store = inject(ProductsStore);
ngOnInit(): void {
void this.store.loadProducts();
}
}
Qui usare RxJS per ogni piccolo filtro sarebbe possibile, ma probabilmente più verboso. I Signals rendono il codice più diretto.
Esempio reale 2: ricerca backend con debounce e cancellazione
Ora immaginiamo una ricerca che interroga il backend a ogni modifica del testo. In questo caso RxJS diventa molto più adatto.
Il motivo è semplice: non vogliamo inviare una richiesta per ogni carattere. Vogliamo aspettare un piccolo intervallo, ignorare valori uguali, cancellare la richiesta precedente e gestire eventuali errori.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
shareReplay,
startWith,
switchMap
} from 'rxjs';
@Component({
selector: 'app-product-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<section>
<h2>Cerca prodotti</h2>
<input
type="search"
[formControl]="searchControl"
placeholder="Scrivi almeno 2 caratteri..."
/>
@if (results$ | async; as results) {
@for (product of results; track product.id) {
<p>{{ product.name }}</p>
}
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSearchComponent {
private readonly api = inject(ProductsApiService);
readonly searchControl = new FormControl('', { nonNullable: true });
readonly results$ = this.searchControl.valueChanges.pipe(
startWith(this.searchControl.value),
map((value) => value.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) {
return of([]);
}
return this.api.searchProducts(query).pipe(
catchError(() => of([]))
);
}),
shareReplay({ bufferSize: 1, refCount: true })
);
}
Qui switchMap è fondamentale. Se l'utente scrive “iph” e poi subito “iphone”, la richiesta precedente può essere annullata e il componente userà solo il risultato più recente.
Questo è un esempio classico in cui RxJS è più naturale dei Signals.
Esempio reale 3: usare RxJS e Signals insieme
In molti casi la soluzione migliore è usare RxJS per il flusso asincrono e Signals per esporre il risultato alla UI.
Possiamo partire da un Observable e convertirlo in signal con toSignal.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import {
catchError,
debounceTime,
distinctUntilChanged,
map,
of,
startWith,
switchMap
} from 'rxjs';
@Component({
selector: 'app-product-search-signal',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<section>
<input
type="search"
[formControl]="searchControl"
placeholder="Cerca prodotto..."
/>
@for (product of results(); track product.id) {
<p>{{ product.name }}</p>
}
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductSearchSignalComponent {
private readonly api = inject(ProductsApiService);
readonly searchControl = new FormControl('', { nonNullable: true });
private readonly results$ = this.searchControl.valueChanges.pipe(
startWith(this.searchControl.value),
map((value) => value.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) {
return of([]);
}
return this.api.searchProducts(query).pipe(
catchError(() => of([]))
);
})
);
readonly results = toSignal(this.results$, {
initialValue: [] as Product[]
});
}
La pipeline resta RxJS, perché deve gestire tempo, debounce e cancellazione. Il template però legge un signal, quindi risulta più semplice.
Esempio reale 4: dettaglio prodotto con parametro di rotta
Un altro scenario molto comune è una pagina dettaglio. Il prodotto da caricare dipende dall'id presente nella rotta.
Il parametro di rotta è uno stream. La chiamata HTTP è uno stream. La UI invece vuole solo lo stato attuale del prodotto.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, distinctUntilChanged, map, of, switchMap } from 'rxjs';
@Component({
selector: 'app-product-detail-page',
standalone: true,
template: `
@if (product(); as product) {
<article>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<p>Prezzo: {{ product.price }} €</p>
</article>
} @else {
<p>Prodotto non trovato.</p>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductDetailPageComponent {
private readonly route = inject(ActivatedRoute);
private readonly api = inject(ProductsApiService);
private readonly product$ = this.route.paramMap.pipe(
map((params) => params.get('id')),
distinctUntilChanged(),
switchMap((id) => {
if (!id) {
return of(null);
}
return this.api.getProductById(id).pipe(
catchError(() => of(null))
);
})
);
readonly product = toSignal(this.product$, {
initialValue: null as Product | null
});
}
Qui la scelta migliore non è solo Signals o solo RxJS. La scelta migliore è usare entrambi: RxJS per reagire al cambio rotta e Signals per mostrare il valore corrente nel template.
Esempio reale 5: carrello e totale ordine
Un carrello è un esempio perfetto per i Signals. Il carrello contiene una lista di righe. Da quelle righe possiamo derivare quantità totale, subtotale, tasse e totale finale.
import { computed, Injectable, signal } from '@angular/core';
export interface CartItem {
productId: number;
name: string;
quantity: number;
unitPrice: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
readonly totalQuantity = computed(() =>
this._items().reduce((total, item) => total + item.quantity, 0)
);
readonly subtotal = computed(() =>
this._items().reduce(
(total, item) => total + item.quantity * item.unitPrice,
0
)
);
readonly vat = computed(() => this.subtotal() * 0.22);
readonly total = computed(() => this.subtotal() + this.vat());
addItem(item: CartItem): void {
this._items.update((items) => {
const existing = items.find((current) => current.productId === item.productId);
if (!existing) {
return [...items, item];
}
return items.map((current) =>
current.productId === item.productId
? { ...current, quantity: current.quantity + item.quantity }
: current
);
});
}
removeItem(productId: number): void {
this._items.update((items) =>
items.filter((item) => item.productId !== productId)
);
}
clear(): void {
this._items.set([]);
}
}
In questo caso usare RxJS sarebbe possibile, ma i Signals sono più semplici. Il totale è sempre derivato dallo stato attuale. Non serve ragionare su una timeline di eventi.
Esempio reale 6: notifiche live con WebSocket
Le notifiche live sono invece un caso molto più adatto a RxJS. Un WebSocket emette messaggi nel tempo. Può cadere, riconnettersi, emettere errori, produrre eventi continui.
import { Injectable } from '@angular/core';
import { retry, share } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
export interface NotificationMessage {
id: string;
title: string;
body: string;
createdAt: string;
}
@Injectable({ providedIn: 'root' })
export class NotificationsService {
readonly messages$ = webSocket<NotificationMessage>(
'wss://example.com/notifications'
).pipe(
retry({ delay: 1000 }),
share()
);
}
Se vogliamo mostrare l'ultima notifica nel template, possiamo convertirla in signal.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-notification-bell',
standalone: true,
template: `
@if (lastMessage(); as message) {
<button type="button">
🔔 {{ message.title }}
</button>
} @else {
<button type="button">🔔 Nessuna notifica</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationBellComponent {
private readonly notifications = inject(NotificationsService);
readonly lastMessage = toSignal(this.notifications.messages$, {
initialValue: null as NotificationMessage | null
});
}
RxJS gestisce lo stream. Signal espone l'ultimo valore alla UI. Questo è il tipo di combinazione più sana.
Interoperabilità: toSignal e toObservable
Angular fornisce strumenti per far dialogare Signals e RxJS. I più comuni sono toSignal e toObservable.
toSignal converte un Observable in un signal.
readonly user = toSignal(this.authService.user$, {
initialValue: null
});
toObservable converte un signal in Observable.
import { signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
readonly query = signal('');
private readonly query$ = toObservable(this.query);
readonly results = toSignal(
this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => this.api.searchProducts(query))
),
{ initialValue: [] as Product[] }
);
Questa tecnica è molto utile quando l'input dell'utente è gestito come signal, ma la ricerca deve usare operatori RxJS.
Errori comuni da evitare
1. Sostituire RxJS ovunque solo perché esistono i Signals
I Signals non eliminano RxJS. Angular continua a usare Observable in molte API. Se provi a sostituire ogni stream con signal, rischi di perdere gli operatori più potenti di RxJS.
2. Usare RxJS per ogni piccolo stato locale
Un booleano come isMenuOpen, una tab attiva o un filtro locale non hanno bisogno di un BehaviorSubject. Un signal è più leggibile.
3. Fare chiamate HTTP direttamente dentro effect senza controllo
effect è utile per side effect come sincronizzare localStorage, title della pagina o log. Non dovrebbe diventare il posto in cui nascondere logica asincrona complessa.
// Da evitare in scenari complessi:
effect(() => {
const query = this.query();
this.api.searchProducts(query).subscribe();
});
Per debounce, cancellazione e gestione errori, una pipeline RxJS è più chiara.
4. Creare troppi computed inutili
computed è potente, ma non deve sostituire ogni metodo o ogni piccola espressione. Usalo quando il valore derivato è importante, riutilizzato o costoso da calcolare.
5. Convertire avanti e indietro senza motivo
Se hai un Observable usato solo nel template, l'async pipe può essere sufficiente. Usa toSignal quando vuoi davvero integrare quel valore nel grafo dei Signals.
Decision tree pratico
- È uno stato locale del componente? Usa Signals.
- È un valore derivato da altri stati? Usa
computed. - È un flusso di eventi nel tempo? Usa RxJS.
- Serve debounce? Usa RxJS.
- Serve cancellare richieste precedenti? Usa RxJS con
switchMap. - Serve mostrare l'ultimo valore nel template? Considera
toSignal. - È una lista già caricata da filtrare localmente? Signals.
- È una ricerca remota mentre l'utente scrive? RxJS.
- È un carrello con totali derivati? Signals.
- È un WebSocket? RxJS.
- È un form con validazione asincrona? RxJS più Signals se serve stato UI.
- È uno store di feature semplice? Signals.
- È orchestrazione di più chiamate HTTP dipendenti? RxJS.
Architettura consigliata in un'app reale
In un progetto Angular professionale, una buona strategia è separare la logica in un livello data-access. I componenti non dovrebbero sapere troppo di HTTP, caching, retry o mapping dati.
src/app/features/products/
products.routes.ts
pages/
products-page.component.ts
product-detail-page.component.ts
components/
product-card.component.ts
product-filters.component.ts
data-access/
products-api.service.ts
products-store.service.ts
models/
product.model.ts
Nel file API service puoi usare RxJS perché Angular HttpClient restituisce Observable.
Nello store puoi salvare il risultato in Signals, creare computed per filtri e totali, ed esporre metodi semplici al componente.
Nel componente puoi leggere Signals nel template, usare async pipe quando lavori direttamente con Observable, oppure convertire con toSignal quando migliora la leggibilità.
Signals e RxJS non si escludono. I Signals rendono Angular più semplice per lo stato locale, lo stato derivato e la UI. RxJS rimane fondamentale per flussi asincroni, stream, cancellazioni, retry, debounce e composizione avanzata.
La scelta migliore è usare lo strumento giusto nel punto giusto. Se stai rappresentando un valore attuale, scegli Signals. Se stai modellando eventi nel tempo, scegli RxJS. Se hai bisogno di entrambi, combinali senza forzature.