Docs
/
Angular
Chapter 7

07 — Reactive Forms

Why Reactive Forms?

Reactive FormsTemplate-Driven Forms
SetupIn TypeScriptIn template (ngModel)
ValidationProgrammaticDirective-based
TestabilityEasy to unit testHarder
Dynamic fieldsEasy (FormArray)Difficult
ComplexityBest for complex formsBest for simple forms

Setup

import { ReactiveFormsModule } from '@angular/forms';

@Component({
  imports: [ReactiveFormsModule],   // Required for reactive forms
})

FormControl, FormGroup, FormBuilder

Basic FormGroup

import { FormGroup, FormControl, Validators } from '@angular/forms';

@Component({
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="name" placeholder="Name">
      @if (form.controls.name.errors?.['required'] && form.controls.name.touched) {
        <span class="error">Name is required</span>
      }

      <input formControlName="email" placeholder="Email">
      @if (form.controls.email.errors?.['email'] && form.controls.email.touched) {
        <span class="error">Invalid email</span>
      }

      <button [disabled]="form.invalid">Submit</button>
    </form>
  `,
})
export class UserFormComponent {
  form = new FormGroup({
    name:  new FormControl('', [Validators.required, Validators.minLength(2)]),
    email: new FormControl('', [Validators.required, Validators.email]),
    age:   new FormControl<number | null>(null, [Validators.min(0), Validators.max(150)]),
  });

  onSubmit() {
    if (this.form.valid) {
      console.log(this.form.value);
      // → { name: 'Alice', email: 'alice@test.com', age: 28 }
    }
  }
}

FormBuilder (Less Verbose)

import { FormBuilder, Validators } from '@angular/forms';

@Component({ ... })
export class UserFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    name:  ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
    age:   [null as number | null, [Validators.min(0)]],
  });
}

NonNullableFormBuilder (No null Resets)

private fb = inject(NonNullableFormBuilder);

form = this.fb.group({
  name:  ['', Validators.required],   // Reset goes to '' not null
  email: ['', Validators.required],
});

this.form.reset(); // → { name: '', email: '' } (not null)

Built-in Validators

ValidatorUsage
Validators.requiredField must have a value
Validators.emailMust be a valid email
Validators.min(n)Number ≥ n
Validators.max(n)Number ≤ n
Validators.minLength(n)String length ≥ n
Validators.maxLength(n)String length ≤ n
Validators.pattern(regex)Match regex pattern
Validators.requiredTrueMust be true (checkboxes)

Custom Validators

Sync Validator

import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';

// Factory function
export function noWhitespace(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    if (!control.value) return null;
    const hasWhitespace = control.value.trim().length === 0;
    return hasWhitespace ? { whitespace: true } : null;
  };
}

export function matchField(fieldName: string): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const parent = control.parent;
    if (!parent) return null;
    const field = parent.get(fieldName);
    return field && control.value !== field.value
      ? { mismatch: { field: fieldName } }
      : null;
  };
}

// Usage
form = this.fb.group({
  password:        ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['', [Validators.required, matchField('password')]],
  username:        ['', [Validators.required, noWhitespace()]],
});

Async Validator

import { AsyncValidatorFn } from '@angular/forms';

export function uniqueEmail(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl) => {
    return userService.checkEmail(control.value).pipe(
      map(exists => exists ? { emailTaken: true } : null),
      catchError(() => of(null)),
    );
  };
}

// Usage
email: ['', [Validators.required, Validators.email], [uniqueEmail(this.userService)]],
//                                                      ↑ Third array = async validators

Cross-Field Validator (Group-Level)

export function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const password = group.get('password')?.value;
  const confirm  = group.get('confirmPassword')?.value;
  return password === confirm ? null : { passwordMismatch: true };
}

form = this.fb.group({
  password: ['', Validators.required],
  confirmPassword: ['', Validators.required],
}, { validators: [passwordMatchValidator] });

// Template
@if (form.errors?.['passwordMismatch']) {
  <span class="error">Passwords do not match</span>
}

FormArray — Dynamic Fields

@Component({
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="name">

      <div formArrayName="phones">
        @for (phone of phones.controls; track $index; let i = $index) {
          <div>
            <input [formControlName]="i" placeholder="Phone {{ i + 1 }}">
            <button type="button" (click)="removePhone(i)">×</button>
          </div>
        }
      </div>
      <button type="button" (click)="addPhone()">+ Add Phone</button>

      <button [disabled]="form.invalid">Submit</button>
    </form>
  `,
})
export class ContactFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    name:   ['', Validators.required],
    phones: this.fb.array([this.fb.control('', Validators.required)]),
  });

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

  addPhone() {
    this.phones.push(this.fb.control('', Validators.required));
  }

  removePhone(index: number) {
    this.phones.removeAt(index);
  }
}

FormArray of FormGroups

form = this.fb.group({
  items: this.fb.array<FormGroup>([]),
});

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

addItem() {
  this.items.push(this.fb.group({
    productId: ['', Validators.required],
    quantity:  [1, [Validators.required, Validators.min(1)]],
    price:     [0, [Validators.required, Validators.min(0)]],
  }));
}

Displaying Errors (Reusable)

// shared/form-error.component.ts
@Component({
  selector: 'app-form-error',
  standalone: true,
  template: `
    @if (control?.invalid && (control?.dirty || control?.touched)) {
      <div class="error">
        @if (control?.errors?.['required']) { <span>This field is required</span> }
        @if (control?.errors?.['email']) { <span>Invalid email</span> }
        @if (control?.errors?.['minlength']) {
          <span>Min {{ control.errors?.['minlength'].requiredLength }} characters</span>
        }
        @if (control?.errors?.['emailTaken']) { <span>Email already taken</span> }
      </div>
    }
  `,
})
export class FormErrorComponent {
  @Input() control: AbstractControl | null = null;
}
<input formControlName="email">
<app-form-error [control]="form.controls.email" />

Patching & Resetting

// Set all values (must include all fields)
this.form.setValue({ name: 'Alice', email: 'alice@test.com', age: 28 });

// Set some values (partial)
this.form.patchValue({ name: 'Alice' });

// Reset to initial state
this.form.reset();

// Reset with specific values
this.form.reset({ name: 'Default', email: '' });

// Mark all as touched (trigger validation display)
this.form.markAllAsTouched();

Key Takeaways

  • Use Reactive Forms for anything beyond a simple search input
  • Use NonNullableFormBuilder to avoid null on form reset
  • Custom validators return null for valid, { errorKey: true } for invalid
  • Async validators go in the third argument of FormControl
  • FormArray handles dynamic lists of fields (phones, addresses, order items)
  • Create a reusable FormErrorComponent to DRY up validation messages
  • Always call markAllAsTouched() on submit to show all validation errors