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:
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()orpatchValue()insidevalueChangestriggers another emission- Bidirectional form logic where field A updates field B and field B updates field A
valueChangessubscription withoutemitEvent: falseon programmatic updates- Computed fields that recalculate and update source fields
- Multiple subscriptions on the same control creating cascading updates
Step-by-Step Fix
- 1.**Use
emitEvent: false** to prevent programmatic updates from triggeringvalueChanges: - 2.```typescript
- 3.this.form.get('price').valueChanges.subscribe(price => {
- 4.const tax = price * 0.2;
- 5.// setValue without emitting another valueChanges event
- 6.this.form.get('tax').setValue(tax, { emitEvent: false });
- 7.this.form.get('total').setValue(price + tax, { emitEvent: false });
- 8.});
- 9.
` - 10.**Use
distinctUntilChanged()** to prevent duplicate emissions: - 11.```typescript
- 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.**Use
takeUntil()** to clean up subscriptions on component destroy: - 2.```typescript
- 3.import { takeUntil, distinctUntilChanged } from 'rxjs/operators';
- 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.For bidirectional calculations, compute in one direction only:
- 2.```typescript
- 3.// BAD: both controls update each other
- 4.priceChanges.subscribe(price => tax.setValue(price * 0.2));
- 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.**Use
valueChangeson the entire form group** for complex interdependent fields: - 2.```typescript
- 3.this.form.valueChanges
- 4..pipe(debounceTime(300), distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)))
- 5..subscribe(formValue => {
- 6.const subtotal = formValue.quantity * formValue.unitPrice;
- 7.const tax = subtotal * formValue.taxRate;
- 8.this.form.patchValue({ subtotal, tax, total: subtotal + tax }, { emitEvent: false });
- 9.});
- 10.
`
Prevention
- Always use
{ emitEvent: false }when setting form values programmatically insidevalueChangeshandlers - Use
distinctUntilChanged()to prevent redundant processing - Design form logic with a clear data flow direction - avoid bidirectional updates
- Debounce
valueChangessubscriptions for expensive computations - Clean up all
valueChangessubscriptions inngOnDestroy - Write unit tests that verify form value changes do not cause infinite loops
- Use Angular's
ngOnDestroylifecycle hook or thetakeUntilDestroyedoperator (Angular 16+) for subscription cleanup