Reactive forms

Angular · Advanced · 14 min

Most existing Angular codebases and many libraries use reactive forms, so you need to read and maintain them.

Before signal forms, the standard for complex forms was reactive forms (ReactiveFormsModule). They remain fully supported and are everywhere in existing code.

The form model is a tree of control objects created in the class:

  • FormControl holds one value plus validation state.
  • FormGroup groups named controls; FormArray holds a dynamic list.
  • FormBuilder (via inject(FormBuilder).nonNullable) shortens creation: fb.group({ email: ['', [Validators.required, Validators.email]] }).

Forms are strictly typed since Angular 14: form.value.email is string | undefined (disabled controls are excluded from value), while form.getRawValue() includes everything. nonNullable makes reset() return to the initial value instead of null.

The template binds with directives: [formGroup]="form" on the <form>, formControlName="email" on inputs, formGroupName/formArrayName for nesting, and (ngSubmit) for submission.

Validation: built-in Validators (required, email, minLength, pattern...), custom ValidatorFns returning an error object or null, async validators, and group-level validators for cross-field rules. Check control.hasError('required'), invalid, touched and dirty.

Changes are exposed as Observables: valueChanges and statusChanges, often combined with RxJS or converted with toSignal. Custom inputs integrate by implementing ControlValueAccessor.

Example

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

@Component({
  selector: 'app-signup', imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="save()">
      <input formControlName="email" />
      @if (form.controls.email.touched && form.controls.email.hasError('email')) { <small>Invalid email</small> }
      <div formGroupName="address"><input formControlName="city" /></div>
      <button [disabled]="form.invalid">Sign up</button>
    </form>`,
})
export class Signup {
  private fb = inject(FormBuilder).nonNullable;
  protected form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    address: this.fb.group({ city: ['', Validators.required] }),
  });
  constructor() { this.form.controls.email.valueChanges.subscribe((v) => console.log('email:', v)); }
  save() {
    console.log(this.form.getRawValue());    // { email: string; address: { city: string } }
    this.form.reset();                       // back to '' thanks to nonNullable
  }
}

Remember

  • FormGroup/FormControl/FormArray model the form in the class; templates bind with formControlName
  • Forms are typed; nonNullable makes reset restore initial values
  • valueChanges/statusChanges are Observables; validators return error objects or null

Watch out for

  • Reading form.value and missing disabled controls; use getRawValue() when you need every field.

Check yourself

  1. A control is disabled. Is its value included in form.value?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: angular.dev/guide/forms/reactive-forms

Builds on

Unlocks

Nothing builds on this yet.