Introduction

Angular reactive forms emit valueChanges events whenever a form control's value is modified. If you programmatically set a form value inside a valueChanges subscription, it triggers another valueChanges event, creating an infinite loop:

typescript
this.form.get('price').valueChanges.subscribe(value => {
    const tax = value * 0.2;
    this.form.get('tax').setValue(tax); // Triggers valueChanges again!
    // If tax control also has a subscription that updates price, infinite loop
});

The browser becomes unresponsive and eventually crashes.

Symptoms

  • Browser freezes after interacting with a form field
  • Console shows "Maximum call stack size exceeded" error
  • CPU usage spikes to 100% when form value changes
  • Form values oscillate between two values rapidly
  • The loop occurs only for specific form field combinations

Common Causes

  • setValue() or patchValue() inside valueChanges triggers another emission
  • Bidirectional form logic where field A updates field B and field B updates field A
  • valueChanges subscription without emitEvent: false on programmatic updates
  • Computed fields that recalculate and update source fields
  • Multiple subscriptions on the same control creating cascading updates

Step-by-Step Fix

  1. 1.**Use emitEvent: false** to prevent programmatic updates from triggering valueChanges:
  2. 2.```typescript
  3. 3.this.form.get('price').valueChanges.subscribe(price => {
  4. 4.const tax = price * 0.2;
  5. 5.// setValue without emitting another valueChanges event
  6. 6.this.form.get('tax').setValue(tax, { emitEvent: false });
  7. 7.this.form.get('total').setValue(price + tax, { emitEvent: false });
  8. 8.});
  9. 9.`
  10. 10.**Use distinctUntilChanged()** to prevent duplicate emissions:
  11. 11.```typescript
  12. 12.import { distinctUntilChanged } from 'rxjs/operators';

this.form.get('price').valueChanges .pipe(distinctUntilChanged()) .subscribe(price => { const tax = price * 0.2; this.form.get('tax').setValue(price * 0.2, { emitEvent: false }); }); ```

  1. 1.**Use takeUntil()** to clean up subscriptions on component destroy:
  2. 2.```typescript
  3. 3.import { takeUntil, distinctUntilChanged } from 'rxjs/operators';
  4. 4.import { Subject } from 'rxjs';

private destroy$ = new Subject<void>();

ngOnInit() { this.form.get('price').valueChanges .pipe(takeUntil(this.destroy$), distinctUntilChanged()) .subscribe(price => { this.form.get('tax').setValue(price * 0.2, { emitEvent: false }); }); }

ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } ```

  1. 1.For bidirectional calculations, compute in one direction only:
  2. 2.```typescript
  3. 3.// BAD: both controls update each other
  4. 4.priceChanges.subscribe(price => tax.setValue(price * 0.2));
  5. 5.taxChanges.subscribe(tax => price.setValue(tax / 0.2));

// GOOD: single source of truth priceChanges.subscribe(price => { tax.setValue(price * 0.2, { emitEvent: false }); total.setValue(price + price * 0.2, { emitEvent: false }); }); ```

  1. 1.**Use valueChanges on the entire form group** for complex interdependent fields:
  2. 2.```typescript
  3. 3.this.form.valueChanges
  4. 4..pipe(debounceTime(300), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)))
  5. 5..subscribe(formValue => {
  6. 6.const subtotal = formValue.quantity * formValue.unitPrice;
  7. 7.const tax = subtotal * formValue.taxRate;
  8. 8.this.form.patchValue({ subtotal, tax, total: subtotal + tax }, { emitEvent: false });
  9. 9.});
  10. 10.`

Prevention

  • Always use { emitEvent: false } when setting form values programmatically inside valueChanges handlers
  • Use distinctUntilChanged() to prevent redundant processing
  • Design form logic with a clear data flow direction - avoid bidirectional updates
  • Debounce valueChanges subscriptions for expensive computations
  • Clean up all valueChanges subscriptions in ngOnDestroy
  • Write unit tests that verify form value changes do not cause infinite loops
  • Use Angular's ngOnDestroy lifecycle hook or the takeUntilDestroyed operator (Angular 16+) for subscription cleanup