Angular: Zero to Senior · Lesson 6 of 11

Reactive Forms & Validation

Reactive forms are the professional way to build forms in Angular. The form model lives in the component class — not in the template — making it fully testable, predictable, and composable.


Setup

Import ReactiveFormsModule in your component:

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

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  // ...
})

FormBuilder Basics

TYPESCRIPT
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { inject } from '@angular/core';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  templateUrl: './login.component.html',
})
export class LoginComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    email:    ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    rememberMe: [false],
  });

  onSubmit(): void {
    if (this.form.invalid) return;
    const { email, password, rememberMe } = this.form.value;
    console.log(email, password, rememberMe);
  }
}

Template:

HTML
<form [formGroup]="form" (ngSubmit)="onSubmit()">

  <div>
    <label>Email</label>
    <input type="email" formControlName="email" />
    @if (form.controls.email.invalid && form.controls.email.touched) {
      @if (form.controls.email.errors?.['required']) {
        <span class="error">Email is required</span>
      }
      @if (form.controls.email.errors?.['email']) {
        <span class="error">Invalid email address</span>
      }
    }
  </div>

  <div>
    <label>Password</label>
    <input type="password" formControlName="password" />
    @if (form.controls.password.invalid && form.controls.password.touched) {
      <span class="error">Password must be at least 8 characters</span>
    }
  </div>

  <label>
    <input type="checkbox" formControlName="rememberMe" />
    Remember me
  </label>

  <button type="submit" [disabled]="form.invalid">Login</button>

</form>

Built-in Validators

TYPESCRIPT
import { Validators } from '@angular/forms';

this.fb.group({
  name:     ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
  email:    ['', [Validators.required, Validators.email]],
  age:      [null, [Validators.required, Validators.min(18), Validators.max(120)]],
  website:  ['', Validators.pattern(/^https?:\/\/.+/)],
  phone:    ['', Validators.pattern(/^\+?[\d\s-]{10,}$/)],
});

Custom Validators

Synchronous custom validator:

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

export function noSpacesValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const hasSpaces = /\s/.test(control.value ?? '');
    return hasSpaces ? { noSpaces: { value: control.value } } : null;
  };
}

Cross-field validator (passwords must match):

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

Apply to the group:

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

In the template:

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

Async Validators

Check uniqueness against an API:

TYPESCRIPT
import { AbstractControl, AsyncValidatorFn, ValidationErrors } from '@angular/forms';
import { Observable, map, catchError, of, debounceTime, switchMap, first } from 'rxjs';

export function usernameAvailableValidator(userService: UserService): AsyncValidatorFn {
  return (control: AbstractControl): Observable<ValidationErrors | null> => {
    return control.valueChanges.pipe(
      debounceTime(400),
      switchMap(value => userService.checkUsernameAvailable(value)),
      map(available => available ? null : { usernameTaken: true }),
      catchError(() => of(null)),
      first(),
    );
  };
}
TYPESCRIPT
username: ['', Validators.required, usernameAvailableValidator(this.userService)],
HTML
@if (form.controls.username.pending) {
  <span>Checking availability...</span>
}
@if (form.controls.username.errors?.['usernameTaken']) {
  <span class="error">Username is already taken</span>
}

FormArray — Dynamic Fields

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

@Component({ /* ... */ })
export class InviteTeamComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    projectName: ['', Validators.required],
    members: this.fb.array([
      this.createMemberControl(),  // start with one
    ]),
  });

  get members(): FormArray {
    return this.form.get('members') as FormArray;
  }

  createMemberControl() {
    return this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      role:  ['viewer', Validators.required],
    });
  }

  addMember(): void {
    this.members.push(this.createMemberControl());
  }

  removeMember(index: number): void {
    this.members.removeAt(index);
  }
}

Template:

HTML
<form [formGroup]="form">
  <input formControlName="projectName" placeholder="Project name" />

  <div formArrayName="members">
    @for (member of members.controls; track $index; let i = $index) {
      <div [formGroupName]="i" class="member-row">
        <input formControlName="email" placeholder="Email" />
        <select formControlName="role">
          <option value="viewer">Viewer</option>
          <option value="editor">Editor</option>
          <option value="admin">Admin</option>
        </select>
        <button type="button" (click)="removeMember(i)">Remove</button>
      </div>
    }
  </div>

  <button type="button" (click)="addMember()">+ Add Member</button>
  <button type="submit" [disabled]="form.invalid">Send Invites</button>
</form>

Watching Value Changes

React to form value changes without submitting:

TYPESCRIPT
ngOnInit(): void {
  // Watch a single control
  this.form.controls.email.valueChanges.subscribe(value => {
    console.log('Email changed:', value);
  });

  // Watch the whole form
  this.form.valueChanges.subscribe(value => {
    console.log('Form value:', value);
    this.autoSave(value);
  });

  // Debounce heavy operations
  this.form.controls.searchTerm.valueChanges.pipe(
    debounceTime(300),
    distinctUntilChanged(),
  ).subscribe(term => this.search(term));
}

Patching Values

TYPESCRIPT
// patchValue — update specific fields (others keep their current values)
this.form.patchValue({
  email: 'alice@example.com',
  rememberMe: true,
});

// setValue — must provide ALL fields
this.form.setValue({
  email: 'alice@example.com',
  password: '',
  rememberMe: false,
});

// Reset — clear all values and reset touched/dirty state
this.form.reset();

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

Form State

TYPESCRIPT
// Validity
form.valid      // all controls pass validators
form.invalid    // at least one control fails
form.pending    // async validator running

// Interaction
form.touched    // user has focused + blurred at least one field
form.untouched
form.dirty      // any value has changed
form.pristine   // no values changed

// Per-control
form.controls.email.valid
form.controls.email.errors   // null or { required: true, email: true, ... }
form.controls.email.hasError('required')

Full Registration Form Example

TYPESCRIPT
@Component({ /* ... */ })
export class RegisterComponent {
  private fb   = inject(FormBuilder);
  private auth = inject(AuthService);
  private router = inject(Router);

  isLoading = false;
  serverError = '';

  form = this.fb.group({
    name:            ['', [Validators.required, Validators.minLength(2)]],
    email:           ['', [Validators.required, Validators.email]],
    password:        ['', [Validators.required, Validators.minLength(8)]],
    confirmPassword: ['', Validators.required],
    agreeToTerms:    [false, Validators.requiredTrue],
  }, { validators: passwordMatchValidator() });

  async onSubmit(): Promise<void> {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }

    this.isLoading = true;
    this.serverError = '';

    try {
      await this.auth.register(this.form.value as RegisterRequest);
      this.router.navigate(['/dashboard']);
    } catch (err: any) {
      this.serverError = err.message ?? 'Registration failed. Try again.';
    } finally {
      this.isLoading = false;
    }
  }
}

Quick Reference

Setup:              imports: [ReactiveFormsModule]
Create:             fb.group({ field: [value, validators] })
Bind:               [formGroup]="form", formControlName="field"
Submit:             (ngSubmit)="onSubmit()"
Validity:           form.valid, form.invalid, control.errors?.['required']
Patch:              form.patchValue({ field: value })
Reset:              form.reset()
Dynamic fields:     FormArray + fb.array([]) + formArrayName
Custom validator:   ValidatorFn → returns ValidationErrors | null
Async validator:    AsyncValidatorFn → returns Observable
Cross-field:        Validator on the group, not individual control
Watch changes:      control.valueChanges.pipe(debounceTime(300)).subscribe()
Lesson Checkpoint