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

Signals vs RxJS in a complex form: what got simpler and what we left in RxJS

We migrated a cross-validated form from RxJS to signals. Here is how derived state got simpler, and why debounced async validation stayed in RxJS.

By Tuurt Team

Signals vs RxJS in a complex form: what got simpler and what we left in RxJS

We had a quote form with cross-field validation: the total depends on quantity and unit price, the discount depends on the client's tier and the total, and a postal code field triggers an async validation call against an external API. All of it was built with valueChanges, combineLatest, and a handful of takeUntilDestroyed calls scattered across the component. Once Angular stabilized signals as a first-class reactivity primitive, we decided to migrate that form to measure, on a real case rather than a documentation example, how far signals alone can take you and where RxJS is still the right tool.

The state before we touched anything

The original component had three pieces of derived state, all built with the same recipe: a combineLatest over the relevant observables, a map to compute the derived value, and a manual subscription writing the result into a component property for the template to read.

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

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

Multiply that by three derived calculations (total, discount, discounted total) and you get three combineLatest blocks, three map calls, and three manual subscriptions, each with its own lifecycle to manage. None of those pieces does anything conceptually hard. The volume of code doesn't come from the difficulty of the calculation, it comes from the ceremony RxJS requires to wire a derived value to the view.

What signals genuinely simplified

For pure, synchronous derived state, computed() removes all of that ceremony, because there's no subscription to manage and no startWith to remember:

quantity = signal(1);
unitPrice = signal(0);

total = computed(() => this.quantity() * this.unitPrice());
discount = computed(() => this.clientTier() === 'gold' ? this.total() * 0.1 : 0);
discountedTotal = computed(() => this.total() - this.discount());

There's no destroyRef, no initialization operator, and the dependency graph is inferred automatically: computed() knows that discountedTotal depends on total and discount because it reads them inside the function, not because we declared it explicitly. This was the clearest win of the whole migration: three combineLatest blocks plus their subscriptions turned into three lines of computed, and the behavior -including correct propagation whenever any input signal changes- is identical.

The other advantage showed up in tests. Testing a computed() means calling the function and comparing the result; testing the equivalent combineLatest meant subscribing, waiting for the emission, and unsubscribing, or leaning on firstValueFrom with the timing issues that brings once there's more than one source.

Where there's no direct equivalent: debounced async validation

The postal code field is the one we didn't migrate, and not for lack of trying. That validation needs three things at once: wait until the user stops typing (debounceTime), ignore repeated values (distinctUntilChanged), and cancel the previous request if a new value arrives before the old one responds (switchMap). This is exactly the kind of time-based combination RxJS was built for, and signals, as they exist today, have no equivalent operator: a computed() recalculates synchronously whenever a dependency changes, it has no notion of "waiting" or of cancelling an in-flight calculation.

We tried solving it with effect() and a manual setTimeout to fake the debounce. It worked, but the code ended up being a poorer reimplementation of what debounceTime already does, without clean cancellation of the previous request and with timer bookkeeping we had to clear by hand on every run of the effect. We dropped that version before it got anywhere near production.

What we landed on was a bridge: keep the RxJS pipeline for the time-based part, and convert the result into a signal with toSignal() so the rest of the form -already living in signals- can consume it without knowing there's an observable behind it.

postalCode = signal('');

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

toObservable() turns the input signal into an observable so we can apply the time-based operators we need, and toSignal() converts the output back so the rest of the component stays in a single mental model. It works, but it's honest to call it what it is: not a migration to signals, but RxJS with a signals-shaped wrapper on both ends of the pipeline, because the part we actually needed -debounce and cancellation- is still handled by RxJS.

The bug that cost us a feedback loop

The issue that took us the longest to diagnose wasn't about RxJS or the interop, it was a poorly written effect(). We had an effect that, whenever discountedTotal changed, pushed the value into a reactive form field with patchValue:

effect(() => {
  this.form.patchValue({ displayedTotal: this.discountedTotal() });
});

The reactive form was still emitting valueChanges, and elsewhere in the component a leftover listener from the half-finished migration was reading that valueChanges and writing it back into one of the input signals. The effect fired, wrote to the form, the form emitted a change, the old listener touched the signal again, and the effect fired once more. It wasn't an infinite loop in the strict sense -each pass changed the value slightly due to floating-point rounding until it settled- but it took us a while to notice that computed() was recalculating dozens of times per keystroke, because Angular doesn't throw an error for this, change detection just does extra work quietly. The fix was finishing the migration of that listener into a proper computed() instead of leaving it half-converted and still coexisting with valueChanges on the same field.

Where we ended up living with both

The final form uses signals for all synchronous derived state -totals, discounts, conditional field enabling- and keeps a small, clearly bounded RxJS pipeline only for the debounced async validation of the postal code. We didn't try to force that one piece into signals, because the result, as shown above, was worse than the RxJS version: more code, no clean cancellation, and a manual timer we would have had to maintain ourselves.

What we check before calling a migration like this done

Before moving a form from RxJS to signals, we identify which derived state is purely synchronous -that goes straight to computed() with no loss- and which state depends on time, cancellation, or coordination with an external resource -that stays in RxJS, bridged at the edges with toSignal() and toObservable(). We check that no valueChanges listener is left writing into a signal that an effect() also writes to, since that combination is exactly what caused our recalculation loop. And we accept that, for this kind of form, the end state isn't "all signals" or "all RxJS", it's whichever tool is simpler for each specific piece.

angular signals rxjs forms
← Back to blog