The reactive forms lesson showed a form in action. Here we open the toolbox: what each class does, how to fill a form with data, and how to react while the user types.
What you will learn
FormControl,FormGroupand nested groupsFormBuilderas a shortcutsetValue()versuspatchValue()andreset()valueChangesandstatusChanges
FormControl: one field
A FormControl holds the value and the state of one input:
import { FormControl, Validators } from '@angular/forms';
const name = new FormControl('', { nonNullable: true, validators: [Validators.required] });
name.value; // ''
name.valid; // false (empty and required)
name.setValue('Ada');
name.valid; // true
name.touched; // false until the user leaves the field
name.dirty; // false until the user changes it
nonNullable: true means reset() returns to the initial value instead of null, and the type stays string.
FormGroup: a set of controls
A FormGroup combines controls into one object. Groups can contain other groups, which models nested data such as an address:
// src/app/forms2/profile-form.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-profile-form',
imports: [ReactiveFormsModule, JsonPipe],
template: `
<form [formGroup]="form" (ngSubmit)="save()">
<p>
<label for="name">Name</label><br />
<input id="name" formControlName="name" />
</p>
<p>
<label for="email">Email</label><br />
<input id="email" formControlName="email" />
</p>
<fieldset formGroupName="address">
<legend>Address</legend>
<p>
<label for="city">City</label><br />
<input id="city" formControlName="city" />
</p>
<p>
<label for="zip">ZIP</label><br />
<input id="zip" formControlName="zip" />
</p>
</fieldset>
<button type="submit" [disabled]="form.invalid">Save</button>
<button type="button" (click)="loadAll()">Load all (setValue)</button>
<button type="button" (click)="loadSome()">Load some (patchValue)</button>
<button type="button" (click)="clear()">Reset</button>
</form>
<p>Status: {{ form.status }} | changes seen: {{ changes }}</p>
<pre>{{ form.getRawValue() | json }}</pre>
`,
})
export class ProfileForm {
private readonly fb = inject(FormBuilder).nonNullable;
protected readonly form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
address: this.fb.group({
city: ['', Validators.required],
zip: ['', Validators.pattern(/^\d{5}$/)],
}),
});
protected changes = 0;
protected lastStatus = '';
constructor() {
// Runs on every value change, with the whole form value.
this.form.valueChanges.subscribe(() => this.changes++);
// Runs when the validity status changes: VALID, INVALID, PENDING or DISABLED.
this.form.statusChanges.subscribe((status) => (this.lastStatus = status));
}
protected loadAll(): void {
// setValue needs EVERY field, in the exact shape of the form.
this.form.setValue({
name: 'Ada Lovelace',
email: 'ada@example.com',
address: { city: 'London', zip: '12345' },
});
}
protected loadSome(): void {
// patchValue accepts a part of the shape, and leaves the rest alone.
this.form.patchValue({ address: { city: 'Paris' } });
}
protected clear(): void {
this.form.reset(); // back to the initial values, and untouched/pristine
}
protected save(): void {
console.log('Saving', this.form.getRawValue());
}
}
// src/app/app.ts
import { Component } from '@angular/core';
import { ProfileForm } from './forms2/profile-form';
@Component({
imports: [ProfileForm],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Profile</h1>
<app-profile-form />
</main>
FormBuilder: less typing
fb.group({...}) is the short way to write new FormGroup({ name: new FormControl(...) }). Each value is a control definition: ['initial', validators]. Using inject(FormBuilder).nonNullable makes every control non-nullable. You can also use fb.control(...) and fb.array(...).
setValue versus patchValue
- Required shape:
setValue(value): Every field must be given;patchValue(value): A partial object is fine - Missing or extra field:
setValue(value): Throws an error;patchValue(value): Ignored - Use for:
setValue(value): Loading a complete, trusted record;patchValue(value): Updating some fields, or loading partial data
Prefer patchValue when data comes from a server, because a missing field will not crash the page. Prefer setValue when you want the compiler and runtime to confirm that nothing was forgotten.
The same two methods work on a single control, on groups, and on FormArray (the dynamic forms lesson). For a FormArray, setValue needs the same number of items as the array has controls, and patchValue updates items by position.
Template-driven forms can be filled too: with ngModel, assign the property in your class (this.model = {...}), or use form.setValue() and form.form.patchValue() on the NgForm instance you get from #f="ngForm".
reset
form.reset() clears the values to their initial value (or null for nullable controls) and marks the form untouched and pristine, so error messages disappear. form.reset({ name: 'Ada' }) resets to chosen values.
valueChanges and statusChanges
Both are Observables available on every control, group and array:
valueChangesemits the new value each time it changes.statusChangesemits'VALID','INVALID','PENDING'(an async validator is running) or'DISABLED'.
A common use is reacting to a single field with RxJS operators:
// src/app/forms2/live-search.ts
import { Component, inject } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, map } from 'rxjs';
@Component({
selector: 'app-live-search',
imports: [ReactiveFormsModule],
template: `
<label for="q">Search</label>
<input id="q" [formControl]="query" />
<p>Searching for: {{ debounced() }}</p>
`,
})
export class LiveSearch {
protected readonly query = new FormControl('', { nonNullable: true });
protected readonly debounced = toSignal(
this.query.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), map((v) => v.trim())),
{ initialValue: '' },
);
constructor() {
this.query.statusChanges.pipe(takeUntilDestroyed()).subscribe((s) => console.log('status', s));
}
}
By default valueChanges and statusChanges also fire for changes made by code. Pass { emitEvent: false } to setValue or patchValue to avoid triggering them, which prevents loops when you update a form from its own subscription.
Reading and changing state
Handy properties on every control: value, valid, invalid, pending, errors, touched, untouched, dirty, pristine, disabled, enabled. Handy methods: enable(), disable(), markAsTouched(), markAllAsTouched(), markAsPristine(), setErrors(), updateValueAndValidity(). getRawValue() includes disabled controls, while value leaves them out.
Test what we claimed
// src/app/forms2/profile-form.spec.ts
import { TestBed } from '@angular/core/testing';
import { FormBuilder } from '@angular/forms';
describe('setValue vs patchValue', () => {
function makeForm() {
const fb = TestBed.inject(FormBuilder).nonNullable;
return fb.group({ name: [''], address: fb.group({ city: [''], zip: [''] }) });
}
it('patchValue accepts a part of the shape', () => {
const form = makeForm();
form.patchValue({ address: { city: 'Paris' } });
expect(form.getRawValue()).toEqual({ name: '', address: { city: 'Paris', zip: '' } });
});
it('setValue throws when a field is missing', () => {
const form = makeForm();
expect(() => form.setValue({ name: 'x' } as never)).toThrow();
});
it('reset returns to the initial values', () => {
const form = makeForm();
form.patchValue({ name: 'Ada' });
form.controls.name.markAsDirty();
form.reset();
expect(form.getRawValue().name).toBe('');
expect(form.pristine).toBe(true);
});
it('valueChanges emits on every change', () => {
const form = makeForm();
const seen: string[] = [];
form.controls.name.valueChanges.subscribe((v) => seen.push(v));
form.controls.name.setValue('a');
form.controls.name.setValue('ab', { emitEvent: false });
form.controls.name.setValue('abc');
expect(seen).toEqual(['a', 'abc']);
});
});
Try it yourself
- Add a "Copy billing to shipping" button that uses
patchValuebetween two groups. - Show a banner when
statusChangesreportsINVALIDafter the user touched a field. - Use
emitEvent: falseto update one field from inside another field'svalueChangeswithout a loop.
Quick check
- What happens if
setValuemisses a field? - Which method clears values and resets touched and dirty state?
- What does
statusChangesemit?
Next, build every kind of field in select, checkbox and radio controls. See the reactive forms guide.