Angular – Reactive Forms and Validation

August 2, 20263 min readUpdated 8/21/2026

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.