Angular provides two form approaches: Template-driven (simple, two-way binding with ngModel) and Reactive (explicit, programmatic, testable). Reactive Forms are the standard for production apps — they provide synchronous access to form values, composable validators, and easier testing without a DOM.

Key Points

  • Template-driven: [(ngModel)] binds to a variable, FormsModule required — simple but hard to test and dynamically build
  • Reactive: FormGroup / FormControl / FormArray from ReactiveFormsModule — explicit model, synchronous, type-safe
  • FormControl<T>: typed since Angular 14 — FormControl<string | null> prevents type errors
  • Validators: Validators.required, Validators.minLength(3), Validators.email, Validators.pattern(regex)
  • Custom validator: function that takes AbstractControl and returns ValidationErrors | null
  • Async validator: returns Promise<ValidationErrors | null> or Observable — for server-side uniqueness checks
  • Cross-field validation: validator on FormGroup — compare two fields (password === confirmPassword)
  • FormArray: dynamic list of controls — add/remove items at runtime (multi-item forms)
  • valueChanges: Observable of form value updates — react to changes with RxJS

Angular Reactive Forms: typed controls, async email validator, cross-field password match, error display pattern

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

// Typed reactive form
@Component({
  standalone: true,
  imports: [ReactiveFormsModule, NgIf],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input formControlName="email" placeholder="Email">
      @if (form.get('email')?.invalid && form.get('email')?.touched) {
        <span class="error">{{ emailError }}</span>
      }
      <div formGroupName="passwords">
        <input type="password" formControlName="password">
        <input type="password" formControlName="confirm">
        @if (form.get('passwords')?.hasError('mismatch')) {
          <span class="error">Passwords do not match</span>
        }
      </div>
      <button [disabled]="form.invalid">Register</button>
    </form>
  `
})
export class RegisterComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    email: ['', [Validators.required, Validators.email], [this.uniqueEmailValidator()]],
    passwords: this.fb.group({
      password: ['', [Validators.required, Validators.minLength(8)]],
      confirm:  ['', Validators.required]
    }, { validators: passwordMatchValidator })
  });

  get emailError(): string {
    const ctrl = this.form.get('email')!;
    if (ctrl.hasError('required'))   return 'Email is required';
    if (ctrl.hasError('email'))      return 'Invalid email format';
    if (ctrl.hasError('taken'))      return 'Email already registered';
    return '';
  }

  submit() {
    if (this.form.valid) console.log(this.form.getRawValue());
  }

  // Async validator — check email uniqueness
  uniqueEmailValidator(): AsyncValidatorFn {
    const svc = inject(UserService);
    return (ctrl: AbstractControl) =>
      ctrl.valueChanges.pipe(
        debounceTime(400),
        switchMap(email => svc.isEmailTaken(email)),
        map(taken => taken ? { taken: true } : null),
        first()
      );
  }
}

// Cross-field validator
function passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
  const pw  = group.get('password')?.value;
  const cfm = group.get('confirm')?.value;
  return pw === cfm ? null : { mismatch: true };
}

Real-World Example

Reactive Forms make form logic unit-testable without a DOM — you can call form.setValue({...}), trigger validators, and assert on form.errors without rendering anything. Template-driven forms require TestBed and async fixture stabilisation for the same level of testing. For any form with more than 3 fields or custom validation logic, reactive forms pay off immediately.