skip to content

An Angular reactive form copies billing into shipping on billing valueChanges, a shipping listener writes back, and the console fills with 'Maximum call stack size exceeded'. Why, and how do emitEvent: false and the events stream help?

level: seniorimportance: should knowfreq 45%

answer

  1. writes emit synchronously
  2. listeners that write re-trigger each other
  3. emitEvent: false breaks the cycle
  4. child emits before its parent updates
  5. ControlEvent and its source

basics

~20 s

setValue() and patchValue() emit valueChanges synchronously, so two listeners that write into each other's controls recurse until the call stack overflows. Pass {emitEvent: false} for programmatic writes, or use the events stream's source to ignore changes you caused.

solid answer

~40 s

Every write through `setValue`, `patchValue`, `reset`, `enable` or `disable` recomputes the control and its ancestors and, unless told otherwise, emits `valueChanges`, `statusChanges` and a `ValueChangeEvent`/`StatusChangeEvent` on the `events` stream, synchronously. A billing listener that patches shipping triggers the shipping listener, which patches billing, and so on until the call stack overflows. Passing `{emitEvent: false}` to the programmatic write updates value and validity silently and breaks the cycle. The unified `events` observable, added in v18, carries typed events with a `source` control, so one subscription on the root can tell which leaf control a change started from. Also note the order: a child emits before its parent recomputes, so reading the parent's `value` inside a child's `valueChanges` gives the old value; use the emitted value or listen on the parent.

code

ts · 29 lines
ts
import {Component, inject} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms';
import {filter} from 'rxjs';

@Component({
  selector: 'app-checkout-addresses',
  imports: [ReactiveFormsModule],
  template: `<form [formGroup]="form"><!-- billing and shipping inputs --></form>`,
})
export class CheckoutAddresses {
  private readonly fb = inject(NonNullableFormBuilder);
  readonly form = this.fb.group({
    sameAsBilling: true,
    billing: this.fb.group({street: '', city: ''}),
    shipping: this.fb.group({street: '', city: ''}),
  });

  constructor() {
    const {billing, shipping, sameAsBilling} = this.form.controls;
    billing.valueChanges
      .pipe(
        filter(() => sameAsBilling.value),
        takeUntilDestroyed(),
      )
      // plumbing write: update shipping without waking shipping's own listeners
      .subscribe(() => shipping.patchValue(billing.getRawValue(), {emitEvent: false}));
  }
}

go deeper

for a junior

Know that valueChanges fires on every change, including your own setValue calls, and that emitEvent: false silences a write.

for a middle

Explain the emission order, child before parent and synchronous, and what the events stream adds: typed events with a source.

for a senior

Diagnose feedback loops and stale parent reads, and classify programmatic writes as user-meaningful or plumbing when choosing emitEvent.

for a principal

Keep cross-field logic one-directional and documented, because bidirectional listeners across a large form are a recurring source of runaway loops.

## When the streams fire Every `AbstractControl` exposes three change streams: | Stream | Emits | Since | |---|---|---| | `valueChanges` | the new value after each recompute | the start | | `statusChanges` | `VALID`, `INVALID`, `PENDING` or `DISABLED` | the start | | `events` | typed `ControlEvent` objects: `ValueChangeEvent`, `StatusChangeEvent`, `PristineChangeEvent`, `TouchedChangeEvent`, `FormResetEvent`, `FormSubmittedEvent` | v18 | They fire from `updateValueAndValidity()`, which runs after user input and after every programmatic write: `setValue`, `patchValue`, `reset`, `enable`, `disable`. Emission is **synchronous**: your subscriber runs inside the call that changed the value, before that call returns. ## The order inside one update 1. The control stores its new value, runs validators and computes its status. 2. It emits on `events`, then `valueChanges`, then `statusChanges`. 3. Only then does it ask its **parent** to recompute, and the parent emits in turn, up to the root. Consequence: inside a child's `valueChanges` subscriber, the parent's `value` is **not yet updated**. Angular's guide warns about exactly this. Use the value passed to the subscriber, read other controls directly, or subscribe to the parent's stream instead. ## How the loop happens A shipping form offers "same as billing": - a listener on `billing.valueChanges` calls `shipping.patchValue(value)`; - a listener on `shipping.valueChanges` (added later for another feature) calls `billing.patchValue(value)`. Each `patchValue` emits synchronously, which calls the other listener, which calls `patchValue` again. There is no asynchronous gap, so the calls nest until the JavaScript engine throws "Maximum call stack size exceeded", usually after a visible stall, and the two groups are left in whatever state the last completed write produced. The same pattern appears with a listener that calls `disable()` on a control in the group it listens to. ## Breaking the cycle - **`{emitEvent: false}`** on programmatic writes: `shipping.patchValue(value, {emitEvent: false})`. Value and validity still update, and the view still shows the new value, but no stream emits, so no listener re-enters. Use it for writes that mirror or load data rather than represent a user decision. - **Filter by `source`** on the `events` stream: every `ControlEvent` carries the control that originated it, and events bubble to ancestors. The `source` is the **leaf** that changed (for example billing's `street` control), not the group you subscribed on, so check whether the source sits inside the billing group before reacting. - **Compare before writing**: skip the patch when the target already holds an equal value. Cheap, but easy to get wrong with objects. - **Make the relationship one-directional**: often the real fix is that shipping should be *derived* from billing while the box is ticked, for example by disabling the shipping group and using billing at submit time. ## Choosing a stream - Need the value only: `valueChanges`. - Need validity only, including async `PENDING`: `statusChanges`. - Need touched, pristine, submit or reset as well, or need to know **which** control changed in a large form: `events`, filtered with `instanceof` checks such as `e instanceof ValueChangeEvent`. Remember that `valueChanges` and friends are long-lived observables; the component must unsubscribe or tie them to its lifetime, which is its own topic. ## Diagnosing the loop 1. Open the stack trace of the `RangeError`: the same `patchValue` or `setValue` and subscriber frames repeat, which names the two controls involved. 2. List every `valueChanges`, `statusChanges` and `events` subscription on the controls involved, including those in child components. 3. Find the pair of listeners that write into each other's controls, or the listener that disables or resets a control in the group it listens to. 4. Break the cycle at the write that is plumbing, not user intent, with `{emitEvent: false}`, and add a test that changes the source field and asserts the listener ran once. ## Why emitEvent is a design tool, not a hack Programmatic writes fall into two groups: **user-meaningful** changes that other logic should react to, and **plumbing**, such as loading a record or mirroring a field. Decide per write which group it is in and pass `emitEvent: false` for plumbing. Documenting that rule in the form's code prevents the next feature from adding the listener that closes the loop.

  • Inside city.valueChanges, form.value.address.city still shows the previous city. Is that a bug?
    No, it is the documented order. The child stores its value and emits first, and only then asks its parent to recompute. Use the value passed to the subscriber, read `city.value`, or subscribe to the parent's `valueChanges` or `events` if you need the whole object.
  • Does {emitEvent: false} stop the input from showing the new value?
    No. `emitEvent` controls only the change streams. The model-to-view update goes through the control's value accessor callbacks, so the bound input still shows the value. Validity is recomputed as well; only listeners miss the change.
  • Why prefer the events stream over combining valueChanges and statusChanges?
    One subscription receives typed events for value, status, touched, pristine, submit and reset, each with the `source` control that caused it, and events bubble to ancestors. That removes the need to wire and correlate several observables, and makes it easy to ignore changes your own code produced.

saying these in an interview costs you the question

  • valueChanges emits asynchronously on the next tick
  • The parent's value is already updated inside a child's valueChanges
  • emitEvent: false stops the view from showing the new value
  • Programmatic setValue() never emits valueChanges
  • The events stream only reports value changes