
RxJS vs. Signals: El impacto en el rendimiento y la arquitectura de aplicaciones Angular
Cualquiera que lleve construyendo aplicaciones Angular por más de un par de años probablemente haya desarrollado una especie de Síndrome de Estocolmo con RxJS. Lo amamos por su robustez al manejar flujos complejos de datos asíncronos, pero también sufrimos con la curva de aprendizaje, el exceso de boilerplate y, sobre todo, con las temidas fugas de memoria (memory leaks) causadas por Subscriptions olvidadas.
Durante mucho tiempo, RxJS fue la respuesta de Angular para todo. ¿Necesitabas hacer una petición HTTP? Observable. ¿Necesitabas escuchar un clic? Observable. ¿Necesitabas gestionar un simple estado de "abierto/cerrado" de un modal o un contador? Adivinaste: Un BehaviorSubject.
Este enfoque de usar streams basados en eventos para gestionar el estado síncrono de la UI cobró su precio en la arquitectura y el rendimiento. Pero con la estabilización de los Signals, Angular cambió las reglas del juego.
En este artículo, vamos a analizar cómo este cambio impacta el código que escribimos día a día, profundizar en las mejoras reales de rendimiento y, lo más importante, definir arquitectónicamente cuándo elegir uno u otro.
El problema del Estado Reactivo con RxJS
Para entender el impacto de los Signals, necesitamos ver las cicatrices que RxJS dejó en nuestras bases de código. Imagina un escenario clásico: una pantalla de checkout donde tienes un producto, una cantidad seleccionada y necesitas calcular el precio total.
En la arquitectura "clásica" de Angular con RxJS, la implementación típica sería algo así:
import { Component, OnDestroy } from '@angular/core';
import { BehaviorSubject, combineLatest, map, Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-checkout',
template: `
<div>
<p>Precio unitario: {{ price$ | async | currency }}</p>
<button (click)="increment()">+</button>
<p>Cantidad: {{ quantity$ | async }}</p>
<h2>Total: {{ total$ | async | currency }}</h2>
</div>
`
})
export class CheckoutComponent implements OnDestroy {
private destroy$ = new Subject<void>();
// El estado necesita ser encapsulado en Subjects
price$ = new BehaviorSubject<number>(150);
quantity$ = new BehaviorSubject<number>(1);
// El estado derivado requiere operadores y combineLatest
total$ = combineLatest([this.price$, this.quantity$]).pipe(
map(([price, quantity]) => price * quantity),
takeUntil(this.destroy$) // <- El clásico boilerplate de limpieza
);
increment() {
this.quantity$.next(this.quantity$.getValue() + 1);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
¿Cuál es el dolor arquitectónico aquí?
- Fricción cognitiva y Boilerplate: Un simple cálculo matemático exige
combineLatest,map,takeUntilySubject. - Timing y Glitches: El
combineLatestsolo emite cuando todos los streams internos emiten al menos una vez. Si no tenemos cuidado con la inicialización, la UI simplemente no se renderiza. - Sobrecarga de memoria: Cada pipe
asyncen la plantilla crea una suscripción separada. Cada operador en la cadena crea una nueva instancia de Observable. En listas largas, esto destruye el rendimiento.
RxJS fue diseñado para coordinar eventos asíncronos a lo largo del tiempo (como websockets, debounce de inputs). Usarlo para retener el valor síncrono de la cantidad de un carrito es matar una mosca con un cañón.
La Llegada de los Signals: Reactividad Síncrona
Los Signals resuelven el problema del estado de la UI de forma mucho más limpia. Un Signal es un "contenedor" (wrapper) que encapsula un valor y notifica a sus consumidores siempre que ese valor cambia.
A diferencia de un Observable, un Signal siempre tiene un valor actual (es síncrono) y no tiene el concepto de "completarse" o "fallar".
Refactoricemos el mismo componente utilizando Signals:
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-checkout-signal',
template: `
<div>
<p>Precio unitario: {{ price() | currency }}</p>
<button (click)="increment()">+</button>
<p>Cantidad: {{ quantity() }}</p>
<h2>Total: {{ total() | currency }}</h2>
</div>
`
})
export class CheckoutSignalComponent {
price = signal(150);
quantity = signal(1);
// Computed: recalcula automáticamente SOLO si price o quantity cambian
total = computed(() => this.price() * this.quantity());
increment() {
this.quantity.update(q => q + 1);
}
}
En este modelo, el código vuelve a parecer TypeScript/JavaScript puro. Sin .getValue(), sin fugas de memoria que gestionar en el ngOnDestroy.
Rendimiento Bajo el Capó: ¿Por qué los Signals son más rápidos?
El cambio de sintaxis es excelente para el desarrollador, pero la verdadera ganancia arquitectónica ocurre en el motor de renderizado.
El cuello de botella de Zone.js
Históricamente, Angular ha dependido de Zone.js. Esta librería intercepta todas las APIs asíncronas del navegador (setTimeout, peticiones de red, eventos de clic) y le avisa a Angular: "Algo pasó en algún lugar. Verifica si el DOM necesita ser actualizado".
Cuando haces clic en el botón + usando el enfoque clásico, Angular recorre todo el árbol de componentes comparando el estado anterior con el nuevo (dirty checking). Es un proceso ineficiente, especialmente en aplicaciones pesadas.
Reactividad de Grano Fino (Fine-Grained Reactivity)
Con los Signals, entramos en la era de la reactividad quirúrgica.
Angular construye un grafo de dependencias reactivo. Cuando usas {{ quantity() }} en el template, el framework crea un enlace directo entre ese nodo del DOM y el Signal específico. Si quantity cambia, Angular no necesita verificar todo el árbol. Sabe exactamente qué parte del HTML necesita ser re-renderizada.
Impacto real en el rendimiento:
- Zoneless: Los Signals han allanado el camino para eliminar Zone.js (
provideExperimentalZonelessChangeDetection()). Esto reduce el tamaño inicial del bundle y elimina los ciclos de Change Detection innecesarios. - Glitch-Free: Si
priceyquantityse actualizan en la misma micro-tarea, eltotal()(que es un computed) será recalculado solo una vez. En RxJS, múltiples emisiones síncronas pueden causar renderizados intermedios indeseados (glitches en la UI). - Menos presión sobre el Garbage Collector: Sin decenas de
Subscriptionscreándose y destruyéndose con cada navegación de ruta, el consumo de memoria cae drásticamente.
¿Cuándo elegir uno u otro? (La Matriz de Decisión)
El mayor error que cometen los equipos al adoptar las versiones recientes de Angular es intentar tirar RxJS a la basura. Los Signals no sustituyen a RxJS por completo.
Resuelven problemas diferentes. La regla de oro arquitectónica actual es:
Los Signals gestionan ESTADO. RxJS gestiona FLUJOS Y EVENTOS.
Usa Signals cuando:
- Estado local del componente: Variables simples (booleanos para modales, contadores, inputs de formularios síncronos).
- Valores derivados: Cualquier dato que dependa matemática o lógicamente de otro estado (usando
computed). - Estado global simple: Servicios que almacenan datos como
usuarioLogueadootemaActivo. - Binding en el Template: Siempre que sea posible, la variable que el HTML consume debe ser un Signal.
Usa RxJS cuando:
- Peticiones de Red (HTTP): El ciclo de vida de un request (pendiente, éxito, error, cancelación) es naturalmente asíncrono y basado en flujo.
- Complejidad de Tiempo (Timing): Operaciones que necesitan
debounceTime,throttleTimeodelay. Los Signals no tienen concepto de tiempo. - Orquestación Asíncrona y Race Conditions: Necesidad de cancelar la petición A porque la petición B comenzó (
switchMap), o combinar múltiples llamadas paralelas (forkJoin). - Eventos basados en push: Websockets, Server-Sent Events (SSE) y flujos de eventos del DOM (mousemove, drag and drop).
Enfoques Arquitectónicos: Uniendo lo mejor de ambos mundos
En aplicaciones empresariales, el código más elegante hoy en día utiliza la Interoperabilidad entre ambas tecnologías.
Imagina un escenario avanzado: una búsqueda de productos. Necesitamos escuchar lo que el usuario escribe, aplicar un debounce para no saturar el backend, cancelar peticiones obsoletas, manejar errores y, finalmente, mostrar el estado actual en pantalla de forma optimizada.
La arquitectura moderna hace esto transitando entre Signals y Observables sin fricción:
import { Component, signal, inject } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap, catchError, of } from 'rxjs';
@Component({
selector: 'app-product-search',
template: `
<!-- El binding de la UI consume y actualiza el Signal directamente -->
<input (input)="updateSearch($event)" placeholder="Busca un producto..." />
<!-- Consumo limpio y de alto rendimiento en la Vista -->
@if (products(); as results) {
<ul>
@for (product of results; track product.id) {
<li>{{ product.name }}</li>
}
</ul>
}
`
})
export class ProductSearchComponent {
private http = inject(HttpClient);
// 1. ESTADO: Signal guarda lo que el usuario escribe (Síncrono y simple)
searchTerm = signal<string>('');
// 2. FLUJO: toObservable() lleva el valor al mundo de RxJS
private searchResults$ = toObservable(this.searchTerm).pipe(
debounceTime(300), // Maneja el tiempo
distinctUntilChanged(), // Evita redundancia
switchMap(term => { // Cancela requests pendientes (Race condition)
if (!term) return of([]);
return this.http.get<any[]>(`/api/products?q=${term}`).pipe(
catchError(() => of([]))
);
})
);
// 3. ESTADO FINAL: toSignal() lo trae de vuelta para consumo optimizado en el template
products = toSignal(this.searchResults$, { initialValue: [] });
updateSearch(event: Event) {
const input = event.target as HTMLInputElement;
this.searchTerm.set(input.value);
}
}
¿Por qué este es el enfoque definitivo?
- Separación explícita de responsabilidades: Donde hay estado (almacenamiento), usamos Signals. Donde hay acción/reacción (comportamiento asíncrono), usamos RxJS.
- Adiós al Async Pipe:
toSignalgestiona la suscripción automáticamente según el ciclo de vida del componente. Se acabaron las fugas de memoria. - Rendimiento Máximo: La UI se renderiza utilizando la Reactividad de Grano Fino de los Signals, totalmente preparada para el futuro Zoneless de Angular.
Conclusión
La adopción de los Signals en Angular no es solo "azúcar sintáctico". Es una refactorización profunda en el motor del framework que corrige problemas históricos de rendimiento (Zone.js) y de ergonomía (exceso de RxJS para tareas triviales).
En lugar de ver este cambio como una amenaza al conocimiento que has construido en RxJS, tómalo como una herramienta de refinamiento. Deja que RxJS brille en lo que mejor hace: orquestar el caos del mundo asíncrono. Y confía en los Signals para entregar esa información a la interfaz de usuario de la manera más rápida, limpia y optimizada posible.
Comentarios
Cargando comentarios...
Únete a la conversación
Inicia sesión con tu cuenta para comentar este artículo.