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

Signals vs RxJS en un formulario complejo: qué se simplificó y qué tuvimos que dejar en RxJS

Migramos un formulario con validación cruzada de RxJS a signals. Así se simplificó el estado derivado, y por qué la validación asíncrona con debounce se quedó en RxJS.

Por Equipo Tuurt

Signals vs RxJS en un formulario complejo: qué se simplificó y qué tuvimos que dejar en RxJS

Teníamos un formulario de cotización con validación cruzada: el total depende de cantidad y precio unitario, el descuento depende del nivel del cliente y del total, y un campo de código postal dispara una validación asíncrona contra una API externa. Todo esto estaba construido con valueChanges, combineLatest y un puñado de takeUntilDestroyed repartidos por el componente. Cuando Angular estabilizó signals como mecanismo de reactividad de primera clase, decidimos migrar ese formulario para medir, en un caso real y no en un ejemplo de documentación, qué tan lejos llega signals solo y dónde seguimos necesitando RxJS.

El estado antes de tocar nada

El componente original tenía tres fuentes de estado derivado, todas calculadas con la misma receta: un combineLatest de los observables relevantes, un map para calcular el valor derivado, y una suscripción manual que escribía el resultado en una propiedad del componente para que el template la leyera.

this.total$ = combineLatest([
  this.form.get('cantidad')!.valueChanges.pipe(startWith(this.form.get('cantidad')!.value)),
  this.form.get('precioUnitario')!.valueChanges.pipe(startWith(this.form.get('precioUnitario')!.value)),
]).pipe(
  map(([cantidad, precio]) => cantidad * precio),
  takeUntilDestroyed(this.destroyRef),
);

this.total$.subscribe(total => this.total = total);

Multiplicado por tres cálculos derivados (total, descuento, total con descuento), esto significaba tres combineLatest, tres map y tres suscripciones manuales, cada una con su propia gestión de ciclo de vida. Ninguna de esas piezas hace algo conceptualmente complicado. El volumen de código no viene de la dificultad del cálculo, viene de la ceremonia que RxJS exige para conectar un valor derivado con la vista.

Lo que signals simplificó de verdad

Para estado derivado puro y sincrónico, computed() elimina toda esa ceremonia porque no hay suscripción que gestionar ni startWith que recordar:

cantidad = signal(1);
precioUnitario = signal(0);

total = computed(() => this.cantidad() * this.precioUnitario());
descuento = computed(() => this.nivelCliente() === 'oro' ? this.total() * 0.1 : 0);
totalConDescuento = computed(() => this.total() - this.descuento());

No hay destroyRef, no hay operador de inicialización, y el grafo de dependencias se infiere solo: computed() sabe que totalConDescuento depende de total y de descuento porque los lee dentro de la función, no porque se lo declaramos explícitamente. Esto fue la mejora más clara de toda la migración: tres bloques de combineLatest más sus suscripciones se convirtieron en tres líneas de computed, y el comportamiento -incluida la propagación correcta cuando cualquier signal de entrada cambia- es idéntico.

El otro punto a favor apareció en los tests. Probar un computed() es llamar a la función y comparar el resultado; probar el combineLatest equivalente exigía suscribirse, esperar la emisión, y desuscribirse, o apoyarse en firstValueFrom con los problemas de timing que eso trae cuando hay más de una fuente.

Donde no hay equivalente directo: validación asíncrona con debounce

El campo de código postal es el caso que no migramos, y no fue por falta de intento. La validación necesita tres cosas a la vez: esperar a que el usuario deje de tipear (debounceTime), ignorar valores repetidos (distinctUntilChanged), y cancelar la petición anterior si llega un valor nuevo antes de que la anterior responda (switchMap). Esto es exactamente el tipo de combinación temporal para la que RxJS fue diseñado, y signals, tal como existen hoy, no tiene un operador equivalente: un computed() se recalcula de forma sincrónica cuando cambia una dependencia, no tiene noción de "esperar" ni de cancelar un cálculo anterior en curso.

Probamos resolverlo con effect() y un setTimeout manual para simular el debounce. Funcionó, pero el código terminó siendo una reimplementación más pobre de lo que debounceTime ya hace, sin cancelación limpia de la petición anterior y con una gestión de temporizadores que teníamos que limpiar a mano en cada ejecución del efecto. Lo abandonamos antes de llegar a producción con esa versión.

La solución que quedó fue puentear: mantener el pipeline de RxJS para la parte temporal, y convertir el resultado a signal con toSignal() para que el resto del formulario -que ya vive en signals- lo consuma sin saber que atrás hay un observable.

codigoPostal = signal('');

codigoPostalValido = toSignal(
  toObservable(this.codigoPostal).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(cp => cp.length === 5 ? this.api.validarCodigoPostal(cp) : of(null)),
  ),
  { initialValue: null },
);

toObservable() convierte el signal de entrada en un observable para poder aplicarle los operadores temporales que necesitamos, y toSignal() convierte la salida de vuelta para que el resto del componente siga en un solo modelo mental. El resultado funciona, pero es honesto reconocer que no es una migración a signals: es RxJS con una fachada de signals en los dos extremos del pipeline, porque la parte que de verdad necesitábamos -debounce y cancelación- sigue resuelta por RxJS.

El error que nos costó un ciclo infinito

El problema que más tiempo nos tomó diagnosticar no fue de RxJS ni de la interoperabilidad, fue un effect() mal escrito. Teníamos un efecto que, al cambiar totalConDescuento, actualizaba un campo del formulario reactivo con patchValue:

effect(() => {
  this.form.patchValue({ totalMostrado: this.totalConDescuento() });
});

El formulario reactivo seguía emitiendo valueChanges, y en otro punto del componente todavía quedaba un listener viejo de la migración a medias que leía ese valueChanges y volvía a escribir en uno de los signals de entrada. El efecto se disparaba, escribía en el form, el form notificaba el cambio, el listener viejo volvía a tocar el signal, y el efecto se disparaba otra vez. No era un loop infinito en el sentido estricto -cada vuelta cambiaba ligeramente el valor por redondeo de punto flotante hasta estabilizarse- pero tardamos en notar que el computed() se estaba recalculando decenas de veces por cada tecla, porque Angular no te avisa de esto con un error, simplemente el change detection trabaja de más. La solución fue terminar la migración de ese listener a un computed() en lugar de dejarlo a medio camino conviviendo con valueChanges del mismo campo.

Dónde quedamos conviviendo con los dos

El formulario final usa signals para todo el estado derivado sincrónico -totales, descuentos, habilitación condicional de campos- y mantiene un pipeline de RxJS acotado, bien delimitado, solo para la validación asíncrona con debounce del código postal. No intentamos forzar esa única parte a signals porque el resultado, como mostramos arriba, era peor que la versión con RxJS: más código, sin cancelación limpia, y con un timer manual que teníamos que mantener nosotros.

Qué revisamos antes de dar por buena esta migración

Antes de migrar un formulario de RxJS a signals, identificamos qué estado derivado es puramente sincrónico -ese pasa directo a computed() sin pérdida- y qué estado depende de tiempo, cancelación o coordinación con un recurso externo -ese se queda en RxJS, puenteado con toSignal() y toObservable() en los bordes. Revisamos que no quede ningún listener de valueChanges escribiendo en un signal que un effect() también escribe, porque esa combinación es la que generó nuestro ciclo de recálculos. Y aceptamos que, para este tipo de formulario, el resultado final no es "todo signals" ni "todo RxJS", es la combinación de ambos en la parte donde cada uno es más simple que el otro.

angular signals rxjs formularios
← Volver al blog