The exact problem: a state machine that both reads FormControl.valueChanges and writes back to the control creates a feedback loop, because every programmatic write re-emits valueChanges and re-enters the reducer.

Context and Prerequisites

This page is the mechanical detail behind the Angular Reactive Forms adapters pattern — read that first for the full snapshot contract and the status-to-state mapping. The goal here is narrower: fold valueChanges and statusChanges into one reducer that drives an explicit machine, and reconcile the machine’s output back into the control without the write re-triggering the read.

The Feedback-Loop Problem

Angular’s FormControl is both a value source and a value sink. valueChanges emits when the value changes; setValue/patchValue change the value. A machine that listens to the first and calls the second is a closed loop unless you cut one edge. The default behaviour of setValue is to emit valueChanges, so the naive wiring below never settles:

// BROKEN: this loops. setValue emits valueChanges, which re-enters the handler.
control.valueChanges.subscribe(value => {
  const next = reduce(machine, { type: 'INPUT', value });
  control.setValue(next.value); // <-- re-fires valueChanges -> handler -> setValue ...
});

The fix is a single flag on the write: { emitEvent: false } tells Angular to update the model without emitting on the observable streams. That breaks the read-write cycle at exactly one point while leaving genuine user input flowing.

Core Implementation

The reducer consumes a merged stream of value and status events, produces an explicit state, and reconciles the value back into the control with emission suppressed.

import { FormControl } from '@angular/forms';
import { DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { merge } from 'rxjs';
import { map, startWith, distinctUntilChanged, scan } from 'rxjs/operators';

type ControlEvent<T> =
  | { kind: 'value'; value: T }
  | { kind: 'status'; status: string };

type MachineState =
  | 'PRISTINE' | 'EDITING' | 'VALIDATING' | 'VALID' | 'INVALID';

interface Machine<T> {
  state: MachineState;
  value: T;
}

/**
 * Binds a FormControl to an explicit state machine with no feedback loop.
 * Returns the reduced machine as a stream the view can render via async pipe.
 */
export function bindControlToMachine<T>(
  control: FormControl<T>,
  destroyRef: DestroyRef,
) {
  // Merge both Angular streams into one typed event source. startWith seeds
  // the current value/status so the machine has an initial state before the
  // user interacts — the streams themselves only fire on subsequent changes.
  const value$ = control.valueChanges.pipe(
    startWith(control.value),
    map((value): ControlEvent<T> => ({ kind: 'value', value })),
  );
  const status$ = control.statusChanges.pipe(
    startWith(control.status),
    // distinctUntilChanged drops Angular's duplicate status echoes: it re-emits
    // VALID on recalculations that did not actually change the status, and each
    // echo would otherwise dispatch a redundant transition.
    distinctUntilChanged(),
    map((status): ControlEvent<T> => ({ kind: 'status', status })),
  );

  return merge(value$, status$).pipe(
    scan<ControlEvent<T>, Machine<T>>(
      (m, event) => reduce(m, event, control),
      { state: 'PRISTINE', value: control.value },
    ),
    // Collapse identical machine snapshots so OnPush is not woken for no-ops.
    distinctUntilChanged((a, b) => a.state === b.state && a.value === b.value),
    // Completes the subscription on component destroy — no destroy$ Subject,
    // no ngOnDestroy. The captured reducer closure is released cleanly.
    takeUntilDestroyed(destroyRef),
  );
}

function reduce<T>(
  m: Machine<T>,
  event: ControlEvent<T>,
  control: FormControl<T>,
): Machine<T> {
  if (event.kind === 'status') {
    const state: MachineState =
      event.status === 'PENDING' ? 'VALIDATING' :
      event.status === 'INVALID' ? 'INVALID' :
      m.state === 'PRISTINE' ? 'PRISTINE' : 'VALID';
    return { ...m, state };
  }

  // event.kind === 'value': normalise the raw input, then reconcile it back
  // into the control WITHOUT emitting, so this write does not re-enter the
  // merged stream and loop. This is the single cut edge of the cycle.
  const normalized = normalize(event.value);
  if (normalized !== control.value) {
    control.setValue(normalized, { emitEvent: false });
  }
  return { state: 'EDITING', value: normalized };
}

function normalize<T>(value: T): T {
  // Example: trim strings so "ab " and "ab" don't read as distinct values.
  return (typeof value === 'string' ? (value.trim() as unknown as T) : value);
}

Step-by-Step Walkthrough

  1. Merge the two streams. valueChanges and statusChanges are separate observables. merge combines them into one event source, and tagging each event with a kind discriminator lets a single reducer handle both. startWith seeds the current value and status so the machine is populated before the first user keystroke.

  2. Deduplicate status echoes. Angular re-emits the same status on recalculations that did not change it. distinctUntilChanged() on status$ drops those echoes before they reach the reducer, so VALID → VALID never dispatches a redundant transition.

  3. Reduce into an explicit state. The scan operator is the reducer: it folds each event into a Machine snapshot. Status events map onto VALIDATING/INVALID/VALID; value events set EDITING and normalize.

  4. Reconcile without emitting. When the reducer normalizes a value and writes it back with setValue(normalized, { emitEvent: false }), the write updates the control model but does not fire valueChanges. This is the one cut edge that prevents the loop. The guard normalized !== control.value avoids an unnecessary write when nothing changed.

  5. Complete on destroy. takeUntilDestroyed(destroyRef) completes the merged subscription when the component is torn down, releasing the reducer closure. No destroy$ Subject, no ngOnDestroy — the same teardown discipline the parent adapter uses. This machine-driven bridge is the low-level counterpart to the schema-driven validation in asynchronous validation strategies, where switchMap plays the cancellation role emitEvent:false plays for loop-breaking.

The loop this pattern exists to break is short enough to draw, and seeing it drawn makes the guard obvious:

The echo cycle, and the two places to cut it Four nodes in a cycle. The machine's context is written into the control. The control emits the new value on valueChanges. The adapter turns that emission into a machine event. The machine assigns new context and writes it into the control again, closing the cycle. Cut one, on the write: pass emitEvent false so the control does not emit for writes that came from the machine. Cut two, before the dispatch: compare the incoming value with the machine's current context and drop the event if they are equal. Either cut alone stops the loop; using both makes the adapter robust to a template that also writes the control. machine context the source of truth setValue FormControl holds the value valueChanges adapter subscription turns it into an event send assign transition context is replaced cut 1 emitEvent cut 2 equality Cut 1 stops writes the machine caused from echoing back. Cut 2 stops any echo, including one from a binding you do not own. Keeping both is cheap: the second is one comparison, and it turns a hang into a no-op if the first is ever missed.

Failure Modes and Edge Cases

1. Forgetting emitEvent:false on one write path

If any write path omits the flag — a patchValue in an error handler, a reset() — that path re-enters the stream and loops. Audit every mutation.

// Every machine-driven write must suppress emission.
control.reset(baseline, { emitEvent: false });
control.patchValue(next, { emitEvent: false });

2. distinctUntilChanged on objects compares by reference

If the control value is an object, the default distinctUntilChanged uses === and treats every new object literal as distinct, letting duplicates through.

// Supply a structural comparator for object-valued controls.
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))

3. Suppressing validation along with the loop

{ emitEvent: false } also suppresses statusChanges, so a reconciling write does not re-run validators. If normalization can change validity, run validation explicitly after the write.

control.setValue(normalized, { emitEvent: false });
control.updateValueAndValidity({ emitEvent: false }); // recompute status silently

4. Machine falls behind on synchronous burst updates

Rapid programmatic updates in the same tick can coalesce, and scan sees only the final value. If you need every intermediate state, debounce upstream rather than relying on per-tick delivery.

Teardown is the other half. An Angular adapter holds three subscriptions and an actor, and every one of them outlives the component unless it is explicitly ended:

Four things to release in ngOnDestroy The valueChanges subscription: if it is not unsubscribed, the closure keeps the component and its machine alive and continues dispatching events after the view is gone. The statusChanges subscription: the same leak, plus validation state written into a destroyed machine. The machine actor: an XState actor keeps its own timers and invoked services running until it is stopped. Pending async validator requests: an in-flight HTTP call resolves into a destroyed context unless its AbortController is aborted or its takeUntil fires. Release this How If you forget valueChanges subscription takeUntilDestroyed() events after the view is gone statusChanges subscription takeUntilDestroyed() writes into a dead machine the machine actor actor.stop() timers and services keep running pending async validation abort the controller a response with nowhere to go Test it: destroy the host component mid-request and assert no error is logged and no dispatch reaches the machine afterwards. Route changes, not unit tests, are where these leaks show up — a form the reader visits ten times leaves ten actors running.

Verification Checklist

When the machine and the control disagree

Two owners of one value will eventually disagree, usually after a reset or an external patch. The rule that keeps recovery predictable is that the machine wins and the control is re-synchronised from it — never the other way round.

Resynchronising after a divergence Step one: divergence is detected when a status emission arrives whose value does not match the machine's context — usually after an external patchValue or a reset called from outside the adapter. Step two: the control is re-written from the machine's context with emitEvent false, so the correction itself does not enter the cycle. Step three: validation is re-run once against the corrected value, because the previous run judged a value that is no longer present. Step four: nothing is announced, because from the reader's point of view nothing changed — announcing here would report a change they did not make. 1 · detect control value ≠ machine context 2 · re-write from context, with emitEvent: false 3 · re-validate once, against the corrected value 4 · stay quiet no announcement — nobody changed Why the machine wins rather than the control The control holds a value; the machine holds the value plus why it is in that state. Taking the control's value discards the reason, so the next transition is computed from a context that no longer matches what produced it. Log every divergence in development: a form that resynchronises regularly has a second writer nobody documented.

FAQ

Why does writing back to the FormControl cause an infinite loop?

setValue and patchValue emit a valueChanges event by default. If your state machine subscribes to valueChanges and also writes to the control in response, the write re-triggers the subscription, which writes again — an unbounded loop. Pass { emitEvent: false } to any machine-driven write so the reconciliation updates the model without re-entering the stream. That single flag cuts exactly one edge of the read-write cycle while leaving genuine user input flowing normally.

Do I need distinctUntilChanged if I already use emitEvent:false?

Yes — they solve different problems. emitEvent:false stops your own writes from re-entering the stream. distinctUntilChanged stops Angular’s own duplicate status emissions: the framework re-emits VALID on recalculations that did not change the status, and each echo would otherwise dispatch a redundant machine transition and wake OnPush change detection. You need both: one guards the write side, the other guards the read side.

Where should I complete the subscription in a standalone component?

Inject DestroyRef and pipe the merged stream through takeUntilDestroyed(destroyRef). It completes the subscription when the component is destroyed without a manual destroy$ Subject and without an ngOnDestroy method, which keeps the reducer’s captured closure from leaking across route changes. In a service that outlives components, prefer an explicit takeUntil(this.destroy$) tied to the service’s own lifecycle instead.


Related

Angular Reactive Forms Adapters