Email copiado — support@tuurt.com
Cargando experiencia
angular · 17 de agosto de 2026 · 6 min

Angular Signals y RxJS: dos herramientas para dos problemas distintos

Signals no reemplaza a RxJS: resuelve otro problema. Repasamos cuando usar cada uno, como conviven con toSignal y toObservable, y los anti-patrones mas comunes al migrar codigo RxJS-heavy.

Por Equipo Tuurt

Angular Signals y RxJS: dos herramientas para dos problemas distintos

Cuando Angular introdujo Signals, la primera pregunta que nos hicieron varios clientes con base de código RxJS-heavy fue la misma: "¿esto significa que dejamos de usar Observables?". La respuesta corta es no. La respuesta larga es que Signals y RxJS resuelven problemas distintos, y tratar a uno como reemplazo del otro es el error que más migraciones a medias hemos visto en el último año.

El problema que cada uno resuelve

RxJS existe para modelar flujos de eventos a lo largo del tiempo: clics, respuestas HTTP, mensajes de WebSocket, cambios de ruta. Un Observable no tiene un valor, tiene una secuencia de valores que pueden llegar en cualquier momento, y su fortaleza real está en los operadores que combinan, transforman y cancelan esos flujos: switchMap, debounceTime, combineLatest, takeUntil. Nada de eso desaparece con Signals porque Signals no intenta resolverlo.

Signals existe para modelar estado: un valor que cambia en el tiempo y que algo necesita leer de forma síncrona, ahora mismo, sin suscribirse a nada. Un Signal<number> no es un flujo, es una celda de memoria reactiva. Cuando lo lees dentro de un computed o de una plantilla, Angular sabe exactamente qué depende de qué sin que tengas que declarar esa dependencia con un operador.

La confusión viene de que ambos son "reactivos" en el sentido amplio de la palabra, pero reactivo a qué es la pregunta que importa. RxJS es reactivo a eventos asíncronos externos. Signals es reactivo a cambios de estado síncrono interno. Un contador de clics, un formulario con validación derivada, un flag de isOpen para un modal: eso es estado, y forzarlo a vivir en un BehaviorSubject con .pipe(map(...)) solo para leer el valor actual en la plantilla es la complejidad que Signals vino a quitar, no a añadir en otro sitio.

// Antes: estado local modelado como flujo, con toda la ceremonia de RxJS
export class FilterComponent {
  private searchTerm$ = new BehaviorSubject<string>('');
  filteredCount$ = this.searchTerm$.pipe(
    map(term => this.items.filter(i => i.name.includes(term)).length)
  );

  onSearch(term: string) {
    this.searchTerm$.next(term);
  }
}
// Después: es estado local, no un flujo, y el código lo refleja
export class FilterComponent {
  searchTerm = signal('');
  filteredCount = computed(() =>
    this.items.filter(i => i.name.includes(this.searchTerm())).length
  );

  onSearch(term: string) {
    this.searchTerm.set(term);
  }
}

El segundo bloque no es "RxJS pero más corto". Es una representación más honesta de lo que el dato realmente es: estado síncrono derivado, no una secuencia de eventos asíncronos que alguien podría cancelar o combinar con otro flujo.

Cuándo usar cada uno

El criterio que aplicamos en revisión de código es simple y evita la mayoría de discusiones de estilo: si el valor tiene un estado actual bien definido y leerlo no implica esperar nada, es un candidato a Signal. Si el valor representa algo que ocurre en el tiempo y necesita operadores de combinación, cancelación o control de concurrencia, sigue siendo un Observable.

Casos claros para Signal: estado de formulario, flags de UI (loading, expanded, selectedTab), valores derivados de otros signals con computed, cualquier cosa que hoy vive en un @Input() simple.

Casos claros para Observable: peticiones HTTP con switchMap para cancelar la anterior si llega una nueva, streams de WebSocket, eventos de formulario con debounceTime antes de disparar una búsqueda, cualquier composición de múltiples fuentes asíncronas con combineLatest o merge.

La zona gris real es el estado que empieza como una petición HTTP puntual y termina siendo consumido como un valor simple en la plantilla. Ahí es donde entra la interoperabilidad, y es la parte que más rompen los equipos que migran rápido.

Interoperabilidad: toSignal y toObservable

Angular no obliga a elegir un bando. toSignal convierte un Observable en una señal de solo lectura, y es la forma correcta de consumir una petición HTTP en una plantilla sin | async ni gestión manual de suscripción:

export class UserProfileComponent {
  private userService = inject(UserService);

  user = toSignal(this.userService.getUser(), { initialValue: null });
}

La plantilla lee user() directamente, sin pipe de async, y Angular gestiona la desuscripción cuando el componente se destruye. Esto no reemplaza la petición HTTP reactiva por dentro: getUser() sigue siendo un Observable con toda su lógica de reintentos, timeouts o cancelación si la necesitas antes de convertirlo. toSignal es el punto donde ese flujo asíncrono aterriza en un valor síncrono para consumo en plantilla, no un sustituto de RxJS para la petición en sí.

toObservable hace el camino inverso, y es menos común pero necesario cuando un signal de estado necesita alimentar un pipeline RxJS existente, por ejemplo para combinarlo con debounceTime antes de disparar una búsqueda:

export class SearchComponent {
  searchTerm = signal('');

  private results$ = toObservable(this.searchTerm).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(term => this.searchService.search(term))
  );

  results = toSignal(this.results$, { initialValue: [] });
}

Este patrón —signal de entrada, pipeline RxJS para la lógica asíncrona, signal de salida— es el que más usamos en formularios de búsqueda. El signal captura la interacción del usuario, que es estado; RxJS gestiona el debounce y la cancelación de peticiones en vuelo, que es control de flujo temporal; y el resultado vuelve a ser un signal porque la plantilla solo necesita leer un valor.

Anti-patrones al portar código RxJS-heavy

El error más frecuente que hemos visto en migraciones es convertir cada BehaviorSubject de estado local en un Signal mecánicamente, pero dejar el .subscribe() manual en el constructor para sincronizar cosas, en vez de usar effect() o, mejor aún, eliminar la sincronización manual porque computed ya la resuelve de forma declarativa. El resultado es código con dos sistemas reactivos coexistiendo sin necesidad, donde un signal dispara un efecto que actualiza otro signal que dispara otro efecto: una cadena de effects que es exactamente el spaghetti de suscripciones anidadas que RxJS bien usado evita.

Otro anti-patrón: usar toSignal sobre un Observable que nunca completa ni tiene valor inicial claro, sin pasar initialValue, y luego pelear con el tipo T | undefined resultante en toda la plantilla. Si el Observable tarda en emitir su primer valor, el signal necesita saber qué mostrar mientras tanto; omitir initialValue traslada ese problema al consumidor en vez de resolverlo en el punto de conversión.

El tercero es el más sutil: envolver un computed alrededor de una llamada que tiene efectos secundarios, como una petición HTTP o un console.log con estado mutable. computed en Angular se ejecuta de forma perezosa y puede recalcularse más veces de las que el código sugiere a simple vista; si esa función dispara una petición de red, terminas con llamadas duplicadas o en momentos impredecibles. Los efectos secundarios asíncronos siguen siendo territorio de RxJS o de effect() explícito, nunca de un computed.

La regla que nos funciona

No migramos código RxJS que ya funciona solo porque Signals es más nuevo. Migramos cuando el código actual está modelando estado síncrono con las herramientas de un flujo asíncrono, porque ahí Signals reduce código real y elimina una fuente de bugs de suscripción olvidada. Donde el problema es genuinamente asíncrono y necesita composición temporal, RxJS se queda, y forzarlo a Signals solo mueve la complejidad de un archivo a otro sin reducirla.

angular rxjs signals frontend
← Volver al blog