Migrar de RxJS a Angular Signals: patrones de refactorización
En 2022 audité un componente de catálogo en Angular.
Tenía 350 líneas de TypeScript. Doce BehaviorSubject, siete combineLatest, cuatro operadores switchMap anidados y tres pipes async duplicados en la plantilla HTML.
El equipo se quejaba de dos problemas: primero, la aplicación se ralentizaba cada vez que el usuario tecleaba en el buscador por la sobrecarga de Zone.js. Segundo, cada tres semanas aparecía un bug de sincronización porque alguien olvidaba desuscribirse de un stream y causaba un memory leak.
El problema no era RxJS, y migrar de RxJS a Angular Signals tampoco significa borrarlo del package.json. RxJS es una librería excelente para flujos asíncronos y eventos complejos. El error fue usarlo como gestor de estado síncrono en la interfaz.
Migrar bien consiste en devolverle a cada herramienta el trabajo que sabe hacer: Signals para el estado síncrono, RxJS para los flujos asíncronos de verdad.
Aquí tienes la guía paso a paso, patrón por patrón. Si quieres el contexto de cómo hemos llegado hasta aquí, lo conté en de callbacks a Signals: la reactividad real del frontend.
Tabla de Equivalencias: De RxJS a Signals
RxJS (Antiguo para Estado) Angular Signals (Moderno)
───────────────────────── ─────────────────────────
BehaviorSubject<T>(value) ───> signal<T>(value)
Observable derivado (map) ───> computed(() => ...)
combineLatest([a$, b$]) ───> computed(() => a() + b())
Subscription manual / tap ───> effect(() => ...)
Observable HTTP ───> rxResource() / httpResource()
Patrón 1: De BehaviorSubject a signal()
En lugar de crear un subject privado y exponer un observable público:
// ❌ Antes (RxJS tradicional)
@Injectable({ providedIn: 39;root39; })
export class CartServiceOld {
private readonly _items$ = new BehaviorSubject<CartItem[]>([]);
readonly items$ = this._items$.asObservable();
addItem(item: CartItem): void {
const current = this._items$.getValue();
this._items$.next([...current, item]);
}
}
La versión moderna con Signals reduce la fricción a una sola línea declarativa:
// ✅ Ahora (Angular Signals)
@Injectable({ providedIn: 39;root39; })
export class CartService {
readonly items = signal<CartItem[]>([]);
addItem(item: CartItem): void {
this.items.update(current => [...current, item]);
}
}
Sin necesidad de pipes async, sin desuscripciones en ngOnDestroy y con lectura síncrona inmediata mediante this.items().
Patrón 2: De combineLatest a computed()
Calcular valores derivados con RxJS requería combinar flujos y recordar filtrar valores nulos iniciales:
// ❌ Antes (RxJS)
readonly totalPrice$ = this.items$.pipe(
map(items => items.reduce((acc, item) => acc + item.price * item.quantity, 0))
);
Con Signals, computed() es memoizado por defecto y solo se recalcula cuando sus dependencias cambian:
// ✅ Ahora (Signals)
readonly totalPrice = computed(() =>
this.items().reduce((acc, item) => acc + item.price * item.quantity, 0)
);
Patrón 3: Efectos colaterales con effect() sin caer en bucles
Usa effect() únicamente para logging, sincronización con APIs externas del navegador (como localStorage o Canvas) o analytics.
El peligro común: Modificar un signal dentro de un effect(). Esto genera bucles reactivos infinitos.
// ⚠️ Si necesitas leer un signal sin suscribirte a sus cambios, usa untracked:
effect(() => {
const currentItems = this.items();
// Leemos el userId sin que este effect se vuelva a disparar si el usuario cambia
const userId = untracked(() => this.authService.userId());
analytics.track(39;Cart Updated39;, { userId, count: currentItems.length });
});
Patrón 4: Conexión Asíncrona con rxResource y toSignal
Para llamadas HTTP y servicios asíncronos que devuelven observables, la interoperabilidad es directa:
import { Component, inject, signal } from 39;@angular/core39;;
import { rxResource } from 39;@angular/core/rxjs-interop39;;
import { ProductService } from 39;./product.service39;;
@Component({
selector: 39;app-product-list39;,
template: `
@if (productsResource.isLoading()) {
<p>Cargando productos...</p>
} @else if (productsResource.error()) {
<p class="error">Error al cargar datos</p>
} @else {
<ul>
@for (product of productsResource.value(); track product.id) {
<li>{{ product.name }} — {{ product.price | currency }}</li>
}
</ul>
}
`
})
export class ProductListComponent {
private readonly productService = inject(ProductService);
readonly categoryId = signal<string | null>(null);
// rxResource gestiona automáticamente estado de carga, valor y error.
// Ojo con la firma: la clave es `stream` (no `loader`) y devuelve un Observable.
readonly productsResource = rxResource({
params: () => ({ category: this.categoryId() }),
stream: ({ params }) => this.productService.getProducts$(params.category)
});
}
Si tu servicio se limita a hacer un GET y devolver el JSON, ni siquiera necesitas rxResource: httpResource() hace el mismo trabajo con la mitad de código. Deja rxResource para cuando necesites operadores de RxJS dentro del loader.
Este es el estándar que enseñamos en profundidad en el curso de Angular Moderno, donde construimos aplicaciones completas sin Zone.js (Zoneless) preparadas para producción.
Para arquitecturas de estado avanzadas con Signal Stores y patrones de persistencia, en Dominicode Labs publicamos repositorios con ejemplos listos para clonar.
También puedes seguir tutoriales en vídeo sobre Signals en el Canal de YouTube de Dominicode.
Qué hacer hoy con esto
Abre tu proyecto de Angular e identifica un componente que tenga al menos dos BehaviorSubject para controlar filtros de búsqueda o modales.
Refactorízalo a signal() y computed(). Elimina los pipes async del HTML.
Verás cómo el archivo pierde un 40% de líneas de código y el comportamiento del componente se vuelve completamente predecible en milisegundos.
Preguntas frecuentes
¿Signals reemplaza a RxJS por completo en Angular?
No. Signals reemplaza a RxJS en la gestión del estado y la reactividad síncrona en la UI. RxJS sigue siendo la herramienta ideal para flujos asíncronos complejos, cancelaciones HTTP (switchMap), debounce de inputs de teclado y websockets.
¿Qué ventaja tiene rxResource frente a usar toSignal() con HttpClient?
rxResource ofrece un manejo integral del ciclo de vida asíncrono, exponiendo automáticamente señales para el estado de carga (isLoading()), el valor obtenido (value()) y los posibles errores (error()).
¿Por qué está desaconsejado cambiar señales dentro de un effect()?
Porque desencadena cascadas de re-renderizado impredecibles y bucles infinitos. Los efectos deben utilizarse exclusivamente para sincronizar con sistemas externos (side effects), no para derivar estado interno.
¿Se puede migrar a Signals de forma gradual o hay que reescribir la aplicación entera?
De forma gradual, servicio a servicio. toSignal() y toObservable() permiten que el código nuevo con Signals y el existente con Observables convivan en el mismo componente, así que puedes migrar un feature por sprint sin bloquear al resto del equipo.
¿Qué pasa con el pipe async al migrar a Signals?
Desaparece. Un signal se lee directamente en la plantilla con items(), sin suscripción ni desuscripción, así que en la migración el async se elimina junto con el ngOnDestroy que existía solo para cerrar streams.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
