Todos los artículos

De RxJS a Signals: guía práctica para migrar el estado en Angular

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.

RxJSSignalsNotas
BehaviorSubject<T>signal<T>()Valor actual, legible de forma síncrona
combineLatest() + map()computed()Dependencias rastreadas automáticamente
.subscribe() para efectos secundarioseffect()Solo hacia el mundo exterior
Pipe asynclectura de valor() en la plantillaNinguna suscripción que gestionar
debounceTime, switchMapse quedan en RxJSPuente 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 count hay que leer toda la cadena de operadores.
  • Riesgo de memory leaks: basta con olvidar un takeUntil para que una suscripción siga viva después de destruir el componente.
  • Estado duplicado: visible y count son 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 como debounceTime.

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

  1. Usa Signals para el estado síncrono y la UI: valores actuales, filtros, selecciones, recuentos.
  2. Usa RxJS para eventos asíncronos y flujos en el tiempo: HTTP, WebSocket, debounce, cancelación. Los operadores RxJS siguen siendo muy valiosos.
  3. Usa computed para todo lo derivado, nunca un effect que copia valores.
  4. Haz que los dos mundos cooperen con rxjs-interop, en lugar de reescribirlo todo.
  5. 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.

💬 Notas de los lectores

0 notas

Escribe una nota

Comparte tu opinión, una sugerencia o un cumplido

Últimas notas

Aún no hay notas. ¡Sé el primero en comentar!