Introducción: la revolución de la reactividad en Angular
Desde las primeras versiones de Angular, RxJS ha sido el motor de la reactividad: llamadas HTTP, formularios, router y eventos pasaban todos por Observables. Una herramienta potentísima, pero exigente: para mostrar un simple valor derivado hacían falta subjects, operadores y suscripciones que cerrar.
Con los Signals, el equipo de Angular ha introducido un modelo más sencillo para el estado de la aplicación: un valor que se lee, se actualiza y del que otros valores dependen automáticamente. El resultado es menos código, una lectura más directa y las bases para un Angular sin Zone.js. En este artículo vemos cómo y cuándo llevar el estado de RxJS a Signals, con una refactorización completa.
RxJS vs Signals: dos herramientas para problemas distintos
La distinción más útil es esta: RxJS describe eventos en el tiempo — clics, mensajes WebSocket, respuestas HTTP, pulsaciones que ralentizar — mientras que los Signals describen el estado actual y los valores que se derivan de él. No compiten: resuelven problemas distintos, como expliqué con más detalle en este artículo.
| RxJS | Signals | Notas |
|---|---|---|
BehaviorSubject<T> | signal<T>() | Valor actual, legible de forma síncrona |
combineLatest() + map() | computed() | Dependencias rastreadas automáticamente |
.subscribe() para efectos secundarios | effect() | Solo hacia el mundo exterior |
Pipe async | lectura de valor() en la plantilla | Ninguna suscripción que gestionar |
debounceTime, switchMap | se quedan en RxJS | Puente con toObservable |
También hay una ventaja en la detección de cambios: cuando una plantilla lee un signal, Angular sabe exactamente qué componentes dependen de ese valor y actualiza solo esos, en lugar de volver a revisar todo el árbol de componentes en cada evento.
Anatomía de la refactorización: un caso real
El escenario es clásico: un dashboard con el listado de productos, un campo de búsqueda de texto y el recuento de los elementos filtrados. Esta es la versión "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();
}
}
Funciona, pero tiene los defectos típicos de este enfoque:
- Mucho código repetitivo: dos subjects, un subject de destrucción, dos suscripciones y un
ngOnDestroy. - Un flujo difícil de seguir: para entender de dónde sale
counthay que leer toda la cadena de operadores. - Riesgo de memory leaks: basta con olvidar un
takeUntilpara que una suscripción siga viva después de destruir el componente. - Estado duplicado:
visibleycountson copias que hay que mantener sincronizadas a mano.
Paso a paso: el estado con Signals
Paso 1: valores e inputs se convierten en signals
Cada BehaviorSubject que representa estado se convierte en un writable signal. Se actualiza con .set() cuando se sustituye el valor y con .update() cuando el nuevo valor depende del anterior:
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
}
Paso 2: la lógica derivada se convierte en computed
El combineLatest con map se convierte en un computed(). Las dependencias no se declaran: Angular las descubre rastreando qué signals se leen. Además, un computed es perezoso y memorizado: solo se recalcula cuando cambia una dependencia, y solo si alguien lo lee.
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 y count ya no son copias que sincronizar: siempre son coherentes por construcción.
Paso 3: los efectos secundarios, con effect
effect() sirve para hablar con el mundo exterior: guardar en localStorage, escribir un log, sincronizar una librería de terceros como un gráfico o un mapa. No sirve, en cambio, para mantener alineados dos signals: en ese caso la respuesta es casi siempre 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);
El puente entre los dos mundos: rxjs-interop
Las llamadas al backend — una API REST o NestJS — siguen siendo, de forma natural, Observables: HttpClient devuelve streams, y ahí es donde hacen falta la cancelación, los reintentos y los operadores temporales. El paquete @angular/core/rxjs-interop ofrece dos puentes:
toSignal(): convierte un stream, por ejemplo una respuesta HTTP, en un signal que se usa directamente en la plantilla.toObservable(): convierte un signal en un stream cuando hacen falta operadores temporales comodebounceTime.
Esta es una búsqueda en tiempo real que usa ambos: el campo de búsqueda es un signal, el debounce y la cancelación de peticiones se quedan en RxJS, y el resultado vuelve a la plantilla como signal. Las dos funciones deben llamarse en un contexto de inyección, por ejemplo como inicializadores de 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) { ... }
Patrón arquitectónico: un store basado en Signals
Cuando el estado lo necesitan varios componentes, la lógica se traslada a un service. La regla es encapsular: el signal modificable se queda privado, hacia fuera se expone una versión de solo lectura con .asReadonly(), y los cambios pasan únicamente por los métodos 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([]); }
}
Para aplicaciones más grandes, Signal Store de @ngrx/signals lleva el mismo modelo al nivel global, con una estructura estándar para estado, valores derivados y métodos.
Conclusiones y checklist
- Usa Signals para el estado síncrono y la UI: valores actuales, filtros, selecciones, recuentos.
- Usa RxJS para eventos asíncronos y flujos en el tiempo: HTTP, WebSocket, debounce, cancelación. Los operadores RxJS siguen siendo muy valiosos.
- Usa
computedpara todo lo derivado, nunca uneffectque copia valores. - Haz que los dos mundos cooperen con rxjs-interop, en lugar de reescribirlo todo.
- Migra un componente cada vez, empezando por los que tienen más suscripciones manuales.
¿Ya has migrado parte de tu aplicación a Signals o tienes dudas sobre dónde trazar la frontera con RxJS? Deja un comentario aquí abajo con tu experiencia: las preguntas más interesantes se convertirán en ideas para los próximos artículos.
