A reactive form is state. It holds what the user has typed, which fields they’ve touched, what’s valid, and what’s disabled. Bugs appear when that state has two owners: the form and a signal, or the form and a store, or a parent form and a child component’s private form. Then you’re writing sync code, and sync code is where edits get lost, autosaves fire for no reason, and two controls update each other until the stack overflows.
The interview angle is usually a design question: “A profile edit page loads data from the server, has three sections in separate components, and autosaves. How do you structure the form state?” This note answers it piece by piece. The behaviour shown comes from @angular/forms 20.3 running under Node; reactive forms in Angular 19 are identical.
One owner for edits
Draw the lines like this:
- The server (or a store) owns saved data. It’s the truth about what’s persisted.
- The
FormGroupowns the edit in progress. From the moment the form is filled until submit, the form is the only place that knows what the user currently wants. - The component that creates the form owns its lifetime. It creates the
FormGroup, fills it, submits it, and throws it away.
Anything that needs to read the form state derives it: the template reads form.controls.email.errors, a preview reads form.value, a signal can be toSignal(form.valueChanges). Nothing else holds a writable copy.
Typed forms and nonNullable
Since Angular 14, forms are typed. Build them with the non-nullable builder so reset() returns to the initial values instead of null:
private fb = inject(FormBuilder).nonNullable;
form = this.fb.group({
name: ['', Validators.required],
email: [''],
plan: ['free' as 'free' | 'pro'],
});The difference shows up the first time someone presses “Cancel”:
after reset (nonNullable): {"name":"","email":"","plan":"free"}
after reset (default FormControl): {"name":null}With plain new FormControl('x'), the type is string | null, and a reset produces null, which then flows into your API call.
Loading server data: setValue versus patchValue
The server response usually doesn’t match the form exactly. It has extra fields (id, createdAt) and sometimes missing ones. The two methods react differently:
setValue missing key: NG01002: Must supply a value for form control with name: 'plan'
setValue extra key: NG01001: Cannot find form control with name: 'createdAt'
patchValue extra key ok: {"name":"Asha","email":"asha@example.com","plan":"pro"}setValue is strict: every control, no extras. Use it when you’ve built the exact shape yourself and want a mismatch to throw. patchValue ignores unknown keys and leaves missing controls alone, which suits server data.
Even better for an initial load is reset(data). It sets the values and marks the form pristine and untouched, so the “unsaved changes” guard and dirty checks start from a clean slate. patchValue doesn’t change dirty status either way (programmatic changes never make a form dirty; only user input does), but it leaves the touched state of a previously edited form as it was.
Reading it back: value versus getRawValue
Disabled controls are excluded from form.value:
value with plan disabled: {"name":"Asha","email":"asha@example.com"}
getRawValue: {"name":"Asha","email":"asha@example.com","plan":"pro"}That’s intentional (a disabled field is “not part of this submission”), and it’s a classic bug when a field is disabled for UX reasons, say the plan can’t be changed while a payment is pending, and the PUT request silently drops it. Decide deliberately: send getRawValue() when the server expects the full object, value when disabled means “don’t send”.
Programmatic updates fire valueChanges
An autosave subscribed to form.valueChanges will fire when you fill the form, not just when the user edits it:
autosave triggered by programmatic patches: 1 (second used emitEvent:false)The first patchValue (loading data) triggered a save of the data that was just loaded. Pass { emitEvent: false } when filling the form from the server, or filter on form.dirty in the autosave pipeline. In Angular 18 and later, form.events also tells you why something changed (a ValueChangeEvent, TouchedChangeEvent, PristineChangeEvent and so on), which helps when you need to separate user edits from programmatic ones.
The same flag prevents the worst ownership bug, two pieces of state syncing each other:
a.valueChanges.subscribe((v) => b.setValue(v.toUpperCase()));
b.valueChanges.subscribe((v) => a.setValue(v.toLowerCase()));
a.setValue('hi'); // a -> b -> a -> b -> ...In the test, a guard counter tripped after 51 emissions; without it this recurses until the stack overflows. emitEvent: false on one side breaks the loop, but the better fix is to ask why two controls hold the same fact. Usually one should be derived.
Mirroring the form in a signal or store
A common pattern in signal-heavy codebases is to copy the form into a signal on every change and also write the signal back into the form when it changes. That’s the echo loop above, spread across two systems, plus a timing gap where they disagree.
If you want the form’s value as a signal, derive it read-only:
formValue = toSignal(this.form.valueChanges, { initialValue: this.form.getRawValue() });
canSubmit = toSignal(this.form.statusChanges.pipe(map((s) => s === 'VALID')), { initialValue: false });If a store needs the draft (to restore it after navigation, for example), write to the store on change and read from it once, when the form is created. Don’t keep both live in both directions.
Angular 21 introduced experimental signal-based forms that make the model a signal and the form a view of it. That’s a different ownership model, summarised in the signal forms overview, and not something to retrofit onto reactive forms by hand.
Splitting a big form across components
The profile page has three sections: personal, address, notifications. Each is its own component. The parent still owns one FormGroup; each child receives its part.
// parent
form = this.fb.group({
personal: this.fb.group({ name: [''], email: [''] }),
address: this.fb.group({ street: [''], city: [''] }),
});<app-personal-section [group]="form.controls.personal" />
<app-address-section [group]="form.controls.address" />// child
@Component({
selector: 'app-address-section',
imports: [ReactiveFormsModule],
template: `<div [formGroup]="group()"><input formControlName="street" /> ...</div>`,
})
export class AddressSection {
group = input.required<FormGroup<{ street: FormControl<string>; city: FormControl<string> }>>();
}The child has no state of its own. Validation, dirty tracking and submission all stay on the parent’s form, so “is the whole profile valid?” is one property. An alternative is to inject ControlContainer with viewProviders so the child can use formGroupName="address" directly against the parent’s FormGroupDirective; it saves an input but makes the child depend on its context, so the explicit input is easier to test.
What doesn’t work well is a child creating its own FormGroup and emitting values up through an output. Now the parent’s form doesn’t know the child’s validity or dirty state unless you rebuild that by hand. If the child really is a self-contained field, make it a proper control instead; see custom controls and value accessors.
Interview practice
A settings page loads the user’s preferences, fills a reactive form, and autosaves through form.valueChanges.pipe(debounceTime(500), switchMap(v => api.save(v))). Three bugs are reported: every visit triggers a save before the user touches anything, a “region” field that’s disabled for some users is reset to the default on the server after any save, and pressing “Discard changes” empties every field. What’s causing each, and what would you change?
Answer
The save on every visit comes from loading the data with patchValue (or setValue), which emits valueChanges. The autosave can’t tell a programmatic fill from a user edit. I’d load with this.form.reset(prefs, { emitEvent: false }), which also marks the form pristine, and add filter(() => this.form.dirty) to the autosave pipeline as a second guard. I’d also switch the autosave from switchMap to concatMap, since cancelling a save on the client doesn’t stop the server applying it, and ordering matters for writes.
The region reset happens because disabled controls are excluded from form.value. The PUT body lacks region, and the server treats a missing field as “use the default”. Sending form.getRawValue() includes it. If the server’s API is a full replacement, that’s the fix; if it supports partial updates, sending only dirty fields would be cleaner still.
“Discard” emptying everything means it calls reset() with no argument. That returns each control to its initial value, which was empty when the form was built (or null for nullable controls), not to the data loaded from the server. The intent is “go back to what was loaded”, so I’d keep the loaded preferences and call this.form.reset(this.loaded, { emitEvent: false }), and build the form with fb.nonNullable so a bare reset() can never produce nulls.