An Angular edit dialog subscribes to its form's `valueChanges` to autosave; after opening it ten times, one keystroke sends ten saves. What is happening, and how do you fix it?
answer
- count the subscribers, not the dialogs
- who owns the FormGroup
- is the dialog really destroyed
- DestroyRef fires on ComponentRef.destroy()
- switchMap inner requests after destroy
basics
~20 sEach dialog instance left a valueChanges subscriber behind, because the form outlives the dialog or the dialog is never destroyed; tie the subscription to the dialog's DestroyRef with takeUntilDestroyed and make sure closing calls ComponentRef.destroy().
solid answer
~40 sTen saves per keystroke means ten live subscribers. Two things usually combine. First, the subscription has no teardown, and the `FormGroup` it listens to **outlives** the dialog: it is held by a parent or a service, so its `valueChanges` `EventEmitter` keeps calling every old dialog's callback. Second, even with teardown, `DestroyRef` only fires when the dialog component is **actually destroyed**; a host that detaches or hides the dialog without calling `ComponentRef.destroy()` never triggers it. Fix both: pipe with `takeUntilDestroyed()` in the dialog's constructor (or pass an injected `DestroyRef` if you subscribe later), place it last so an autosave `switchMap` cannot keep an inner request alive, and make the close path destroy the component. Then verify by counting subscribers or saves after repeated opens.
code
ts · 24 linesimport { Component, DestroyRef, OnInit, inject, input } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap } from 'rxjs';
import { DraftApi } from './draft-api';
@Component({
selector: 'app-edit-dialog',
imports: [ReactiveFormsModule],
template: `<form [formGroup]="form()"><input formControlName="title" /></form>`,
})
export class EditDialog implements OnInit {
readonly form = input.required<FormGroup>(); // owned by the parent: outlives the dialog
private readonly api = inject(DraftApi);
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.form().valueChanges.pipe(
debounceTime(500),
switchMap(value => this.api.save(value)),
takeUntilDestroyed(this.destroyRef), // last: also cancels an in-flight save on destroy
).subscribe();
}
}go deeper
Recognise that each open adds a subscription, and that it must be ended when the dialog is destroyed, using takeUntilDestroyed.
Explain why valueChanges never completes, how control ownership keeps it alive, and where to place takeUntilDestroyed in an autosave pipe.
Diagnose from request counts, check that the dialog host really destroys the component, and prove the fix with a destroy test.
Set rules for dialog hosts and shared form state so component lifetime and subscription lifetime cannot drift apart across teams.
## The symptom An edit dialog autosaves the user's changes: ```ts ngOnInit() { this.form.valueChanges .pipe(debounceTime(500), switchMap(v => this.api.save(v))) .subscribe(); } ``` After the dialog has been opened and closed ten times, one keystroke produces ten `save` requests. The Network tab shows them in a burst after the debounce. ## What is actually happening Ten requests per keystroke means **ten live subscriptions**, one left behind by every dialog instance. Two independent causes can produce that, and both are common. ### Cause 1: the form outlives the dialog `valueChanges` is an `EventEmitter` created with the control and it **never completes**. If the dialog creates its own `FormGroup`, then closing and destroying the dialog makes the form, the subscription and the component unreachable together, and nothing keeps firing. But edit dialogs often receive their form from outside: - a parent component builds the `FormGroup` and passes it through an input; - a service keeps the draft form so it survives closing the dialog; - the dialog listens to a control of a larger page form. In each case the control outlives every dialog instance, and each instance adds one more subscriber to the same emitter. ### Cause 2: the dialog is never destroyed `takeUntilDestroyed()` and `ngOnDestroy` both depend on the component being **destroyed**. A dialog created with `ViewContainerRef.createComponent()` or `createComponent()` is destroyed when its `ComponentRef.destroy()` is called or its container is cleared. A home-grown dialog host that only **detaches** the view, hides it with CSS, or keeps the `ComponentRef` in a list for reuse never fires the destroy callbacks. Every "close" leaves a live component with a live subscription. ## The fix, step by step 1. **Tie the subscription to the dialog.** Subscribe in the constructor with `takeUntilDestroyed()`, or inject `DestroyRef` into a field and use `takeUntilDestroyed(this.destroyRef)` from `ngOnInit`. 2. **Put it last.** `takeUntilDestroyed` completes only what is upstream. Placed before `switchMap`, it would let an in-flight save (or any endless inner stream) continue after destroy. Last in the pipe, completion also unsubscribes from the current inner request. 3. **Make close mean destroy.** Check the close path calls `ComponentRef.destroy()` (or removes the view from its container). If the host deliberately keeps instances for reuse, then subscriptions must be started and stopped on open and close instead of construction and destroy. 4. **Question the ownership.** If the form lives in a parent or service for a reason, keep it there, but let the dialog own only its own subscription. ## How to verify - Open and close the dialog several times, then type once: exactly one save request should follow the debounce. - Add a `finalize(() => console.count('autosave torn down'))` before `subscribe` while debugging; the count should rise once per close. - In a unit test, create and destroy the component through `TestBed`, emit a value on the external form, and assert the save spy is not called. | Check | Expected after the fix | |---|---| | saves per keystroke after N opens | 1 | | teardown count after N closes | N | | save calls after `fixture.destroy()` | 0 | ## What interviewers listen for - Reading "N saves" as **N subscribers**, not as a debounce or network problem. - **Control ownership** as the reason `valueChanges` outlives the component. - **`DestroyRef` fires only on real destroy**, so the host's close path matters. - **Placement** of `takeUntilDestroyed` relative to `switchMap`. - A **verification** step, not just a code change.
- If the dialog creates its own FormGroup, does a missing teardown still cause duplicate saves?Not after the dialog is destroyed: the form, its emitter and the subscription become unreachable together, so nothing fires. It still matters if the dialog is never destroyed, or if an in-flight save completes after close, so tying it to DestroyRef remains the safe default.
- What changes if takeUntilDestroyed is placed before switchMap in the autosave pipe?On destroy, the source side completes, but `switchMap` keeps its current inner save subscription until that request finishes, so a save can still run and its result still arrives. With an endless inner stream it would never stop. Placing the operator last ends the inner subscription as well.
saying these in an interview costs you the question
- The duplicate saves come from debounceTime not working
- valueChanges is completed automatically when the dialog closes
- takeUntilDestroyed fires when a dialog is hidden or detached
- Moving the subscription to ngOnInit fixes the leak by itself
- A form created inside the dialog leaks after the dialog is destroyed