A reactive form is defined in the class. The template binds to it, rather than being the source of truth.
readonly form = this.fb.nonNullable.group({
customerName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
phone: [''],
addressLine1: [''],
city: [''],
state: [''],
postalCode: ['', Validators.pattern(/^\d{5}$/)],
});<form id="checkout-form" [formGroup]="form" (ngSubmit)="createOrder()">Each entry is [initialValue, validators]. It needs
ReactiveFormsModule in the component's imports.
nonNullable
this.fb.nonNullable.group rather than this.fb.group, and it is worth
being deliberate about.
By default a control's type is string | null, because reset() sets
it back to null. That single | null then propagates into every piece of code
that reads the value. nonNullable makes reset() restore the initial value
instead, so the type is just string.
This is the payoff of typed forms, which arrived in Angular 14. Before that every value was
any and a typo in a control name was a runtime surprise.
form.controls.customerName is now checked at build time.
Reading and writing
const { customerName, email, phone } = this.form.getRawValue();getRawValue() rather than .value, because .value omits
disabled controls. If a field is disabled and you still need its value, that difference is the bug.
this.form.patchValue({patchValue updates the keys you give it; setValue requires every
control and throws otherwise — occasionally what you want, as a check that the shape still
matches.
Conditional validation
This is the thing template-driven forms cannot do. Checkout's address fields are required only when the order is a delivery and no saved address was chosen:
for (const control of [addressLine1, city, state]) {
control.setValidators(required ? [Validators.required] : []);
control.updateValueAndValidity({ emitEvent: false });
}Two details that are easy to get wrong.
setValidators replaces the list; it does not add to it. Which is
why the postal code re-states its pattern in both branches — dropping it in the "not required" case
would let a delivery address through with a malformed ZIP.
Validators are not re-run until you ask. updateValueAndValidity()
is what applies them. Without it, switching from delivery to carryout leaves the street address
required and the form permanently invalid — with no visible reason.
{ emitEvent: false } stops that recalculation from firing
valueChanges and re-entering the effect that triggered it.
FormArray
For a list of controls rather than named ones:
sizes: this.fb.array(
SIZES.map((size) =>
this.fb.nonNullable.group({
size: [size],
price: [0, [Validators.required, Validators.min(0.01)]],
}),
),
),The admin product editor uses it for the three size prices. React manages the same thing as an array in state plus an index-based setter; here the array is part of the form, so validation and dirty-tracking come with it.
Showing errors at the right moment
invalid(control: 'customerName' | 'email' | 'addressLine1' | 'city' | 'state' | 'postalCode'): boolean {
return this.submitted() && this.form.controls[control].invalid;
}Errors appear only after a submit has been attempted. That is one of the two defensible
policies — the other is on blur, using touched. What is not defensible is showing them
while the user is still typing into an empty form.
Server errors
Client validation is a convenience; the server is the authority, and it will reject things the browser could not know about. A 400 with per-field messages needs mapping back onto controls, which is the other reason non-trivial forms are reactive: there is an object to map onto.
The pizza app keeps those in a fieldErrors signal alongside the form rather than
in the controls themselves, so a server error clears on the next submit instead of fighting the
client-side validators.
Custom validators
export function noLeadingZero(control: AbstractControl): ValidationErrors | null {
return String(control.value).startsWith('0') ? { leadingZero: true } : null;
}A function returning null for valid, or an object describing what is wrong. Async
validators — checking an email is not taken — return an observable of the same, and the control is in
a pending state while it runs.
What is next
Where state lives once an app has more of it than one component can hold.