On this page

Reactive forms with validation

15 min read TextCh. 3 — Architecture

Reactive forms

Reactive forms define the form structure in TypeScript, giving you full control over validation, state, and transformations.

Setting up Reactive Forms

Import ReactiveFormsModule in the component:

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

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

FormBuilder

FormBuilder is a service that simplifies control creation:

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

const fb = inject(FormBuilder);

const form = fb.nonNullable.group({
  name: ['', Validators.required],
  age: [0, [Validators.required, Validators.min(18)]],
});

Control types

Type Usage Example
FormControl A single value Text input, checkbox
FormGroup Group of controls Complete form
FormArray Dynamic list Add/remove fields

FormArray example

readonly form = this.fb.nonNullable.group({
  title: [''],
  tags: this.fb.array([
    this.fb.control('angular'),
    this.fb.control('typescript'),
  ]),
});

addTag(value: string): void {
  const tags = this.form.controls.tags;
  tags.push(this.fb.control(value));
}

Built-in validators

Angular includes common validators:

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

fb.control('', [
  Validators.required,        // Required field
  Validators.minLength(3),    // Minimum length
  Validators.maxLength(50),   // Maximum length
  Validators.email,           // Email format
  Validators.min(0),          // Minimum value
  Validators.max(100),        // Maximum value
  Validators.pattern(/^\d+$/), // Regular expression
]);

Custom validators

Create functions that receive an AbstractControl and return errors or null:

function corporateEmail(control: AbstractControl): ValidationErrors | null {
  const email: string = control.value;
  if (!email) return null;
  return email.endsWith('@company.com')
    ? null
    : { notCorporateEmail: true };
}

Displaying errors in the template

@if (form.controls.email.touched
     && form.controls.email.errors; as errors) {
  @if (errors['required']) {
    <span class="error">Required field</span>
  } @else if (errors['email']) {
    <span class="error">Invalid email format</span>
  }
}

Form state

Property Description
.valid All controls are valid
.invalid At least one control is invalid
.touched The user interacted with the control
.dirty The value was modified
.pristine The value has not been modified

Practice

  1. Create a contact form: Use FormBuilder to build a form with name, email, and message fields. Add Validators.required and Validators.email where appropriate.
  2. Implement a custom validator: Create a validator function that checks whether a text field contains forbidden words. Attach it to a form control.
  3. Display errors conditionally: Use @if with .touched and .errors to show specific error messages below each field only after the user has interacted with them.

In the next lesson, we will learn how to connect with APIs using HttpClient.

nonNullable
Use fb.nonNullable.group() so that controls don't accept null. This way getRawValue() returns strict types without null, improving TypeScript inference.
Don't mix approaches
Don't combine Template-driven forms (ngModel) with Reactive forms (formGroup) in the same form. Choose one or the other. Angular 21 recommends Reactive forms for complex forms.
import {
  Component, inject, signal,
  ChangeDetectionStrategy,
} from '@angular/core';
import {
  ReactiveFormsModule, FormBuilder,
  Validators, AbstractControl, ValidationErrors,
} from '@angular/forms';

// Custom validator
function strongPassword(control: AbstractControl): ValidationErrors | null {
  const value: string = control.value;
  if (!value) return null;

  const hasNumber = /\d/.test(value);
  const hasUppercase = /[A-Z]/.test(value);
  const hasMinLength = value.length >= 8;

  if (hasNumber && hasUppercase && hasMinLength) return null;
  return {
    weakPassword: {
      requiresNumber: !hasNumber,
      requiresUppercase: !hasUppercase,
      requiresMinLength: !hasMinLength,
    },
  };
}

@Component({
  selector: 'app-register',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule],
  templateUrl: './register.html',
})
export class Register {
  private readonly fb = inject(FormBuilder);
  readonly submitted = signal(false);

  readonly form = this.fb.nonNullable.group({
    name: ['', [Validators.required, Validators.minLength(3)]],
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, strongPassword]],
    acceptTerms: [false, Validators.requiredTrue],
  });

  submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    const data = this.form.getRawValue();
    console.log('Registration:', data);
    this.submitted.set(true);
  }
}