Week 12: Reactive Forms

Reactive forms model your form's entire state as data — every control, every value, every validity check is a plain object you can inspect, test, and reason about outside the template. This week you'll build forms that scale past a single text input.

Phase 3 of 7 Week 12 of 26 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build and validate non-trivial forms with full type safety
  • Write custom, async, and cross-field validators
  • Grow and shrink a form dynamically with FormArray

1. FormControl, FormGroup & FormArray (Typed Forms)

Three building blocks, composable into any shape: a single value (FormControl), a fixed set of named controls (FormGroup), and a variable-length list of controls (FormArray). Since Angular 14, forms are strictly typed by default — no more any leaking through your form values.

signup-form.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  templateUrl: './signup-form.html',
  imports: [ReactiveFormsModule],
})
export class SignupForm {
  form = new FormGroup({
    email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
    password: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] }),
  });

  onSubmit() {
    if (this.form.invalid) return;
    console.log(this.form.value); // typed as { email: string; password: string }
  }
}
signup-form.html
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <input formControlName="email" type="email" />
  <input formControlName="password" type="password" />
  <button type="submit" [disabled]="form.invalid">Sign up</button>
</form>

nonNullable: true is what makes form.value.email typed as string instead of string | null — worth setting explicitly on every control unless you genuinely want null to be a valid state (a control that can be reset to empty, for instance).

2. Built-in & Custom Synchronous Validators

Validators.required, .email, .minLength(), and a handful of others cover common cases. A custom validator is just a function matching a specific signature: given a control, return an error object or null.

strong-password.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function strongPassword(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value as string;
    const hasNumber = /\d/.test(value);
    const hasUpper = /[A-Z]/.test(value);

    if (value && hasNumber && hasUpper) {
      return null; // valid
    }
    return { strongPassword: { hasNumber, hasUpper } };
  };
}
using it
password: new FormControl('', {
  nonNullable: true,
  validators: [Validators.required, Validators.minLength(8), strongPassword()],
}),

The returned error object's shape is entirely up to you — { strongPassword: {...} } here — and it's exactly what you read back out in the template to show a specific message: form.controls.password.errors?.['strongPassword'].

3. Async Validators & Debounced Validation

An async validator returns an Observable or Promise instead of a value directly — for checks that need a server round-trip, like confirming a username isn't already taken.

username-taken.validator.ts
import { inject } from '@angular/core';
import { AbstractControl, AsyncValidatorFn } from '@angular/forms';
import { UsersService } from './users.service';
import { map, debounceTime, switchMap, first } from 'rxjs';

export function usernameTaken(): AsyncValidatorFn {
  const users = inject(UsersService);

  return (control: AbstractControl) =>
    control.valueChanges.pipe(
      debounceTime(400),
      switchMap((value) => users.checkAvailable(value)),
      map((available) => (available ? null : { usernameTaken: true })),
      first()
    );
}

Angular automatically marks the control as PENDING while an async validator is running, which you can check in the template to show a "checking…" state — worth doing, since the debounce alone means there's a real, user-visible delay before the result comes back.

4. Cross-Field Validation Patterns

Some validation needs more than one field at once — confirming two password fields match, for instance. That validator belongs on the group, not either individual control, since it needs to read both.

passwords-match.validator.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

export function passwordsMatch(): ValidatorFn {
  return (group: AbstractControl): ValidationErrors | null => {
    const password = group.get('password')?.value;
    const confirm = group.get('confirmPassword')?.value;
    return password === confirm ? null : { passwordsMismatch: true };
  };
}
using it on the group
form = new FormGroup(
  {
    password: new FormControl('', { nonNullable: true }),
    confirmPassword: new FormControl('', { nonNullable: true }),
  },
  { validators: [passwordsMatch()] }
);

The resulting error lives on form.errors (the group), not on either individual control — a common source of confusion the first time you look for it in the wrong place.

5. Dynamically Adding/Removing Form Controls

FormArray holds a variable-length list of controls — exactly what you need for an "add another" pattern like multiple phone numbers or emergency contacts.

contact-form.ts
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';

export class ContactForm {
  form = new FormGroup({
    name: new FormControl('', { nonNullable: true }),
    phoneNumbers: new FormArray([
      new FormControl('', { nonNullable: true, validators: [Validators.required] }),
    ]),
  });

  get phoneNumbers() {
    return this.form.controls.phoneNumbers;
  }

  addPhoneNumber() {
    this.phoneNumbers.push(new FormControl('', { nonNullable: true, validators: [Validators.required] }));
  }

  removePhoneNumber(index: number) {
    this.phoneNumbers.removeAt(index);
  }
}
contact-form.html
<div formArrayName="phoneNumbers">
  @for (control of phoneNumbers.controls; track $index) {
    <input [formControlName]="$index" />
    <button type="button" (click)="removePhoneNumber($index)">Remove</button>
  }
</div>
<button type="button" (click)="addPhoneNumber()">Add another</button>

Notice track $index here, not a stable ID — the array holds interchangeable text inputs with no meaningful identity of their own, exactly the case from Week 6 where index-based tracking is the right call.

6. Form State & UX Patterns

Every control tracks more than just its value — dirty (changed by the user), touched (blurred at least once), and pending (an async validator is running) all drive real UX decisions.

error display pattern
@if (form.controls.email.invalid && form.controls.email.touched) {
  <p class="error">Enter a valid email address.</p>
}

Gating the error on touched, not just invalid, is the whole trick — without it, every field shows an error the instant the form loads, before the user has even typed anything. This one pattern is responsible for more form-UX improvement than almost anything else in this lesson.

7. Hands-on Exercise

Hands-on

Build a multi-step signup form

Combine every technique from this lesson into one realistic form.

Requirements:

  1. Step 1: email (required, valid format) and a fake async usernameTaken validator on a username field, showing a "checking availability…" state while pending.
  2. Step 2: password and confirmPassword, with the group-level passwordsMatch validator, plus the strongPassword custom validator on password itself.
  3. Step 3: a FormArray of "skills" (text inputs) with add/remove buttons, requiring at least one entry.
  4. A "Next" button per step disabled until that step's controls are valid; only mark controls as touched when the user tries to advance, so errors don't appear prematurely.
  5. A final review step showing the full typed form.value, and a submit handler that logs it.
Hint

To mark every control in a step touched at once (rather than one at a time), call .markAllAsTouched() on that step's FormGroup when the user clicks "Next" while it's still invalid.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does setting nonNullable: true on a FormControl actually change?

It changes the control's TypeScript type so its value is typed as, e.g., string instead of string | null — and it changes runtime behavior too: calling .reset() on the control resets it to its initial value rather than null. Without it, every read of the value needs a null check even when you never intend the field to actually be empty.

Q2

Why does a cross-field validator like passwordsMatch go on the FormGroup instead of the confirmPassword control?

A validator on a single control only receives that control's own value — it has no way to read a sibling control's value. The group is the lowest level that has access to both password and confirmPassword at once, so that's where a validator comparing them has to live.

Q3

Why gate an error message on control.touched in addition to control.invalid?

An empty required field is invalid from the moment the form loads — checking only invalid would show every error immediately, before the user has interacted with anything. touched only becomes true once the control has been focused and blurred at least once, so gating on both means errors only appear after the user has actually had a chance to fill the field in.

Q4

Why use track $index for the phone-numbers FormArray instead of tracking by some ID?

The controls in this array are interchangeable text inputs with no inherent identity of their own — there's no natural "ID" a phone-number input has independent of its position. Per Week 6's guidance, index-based tracking is the right call precisely when items genuinely lack a stable identity, which is exactly this case.