Forms

This section documents modern, standalone Angular — signals, the built-in @if / @for / @switch control flow, @defer, typed reactive forms, provideHttpClient, functional guards and interceptors, and server-side rendering with hydration — as described by the official documentation at angular.dev, which is the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against angular.dev before being relied on in production. Angular ships a major release roughly every six months and its APIs continue to evolve: the examples here target the current major release; where a consulted source disagrees with the current documentation, the documentation wins and the difference is noted.

This section’s bibliography lists the reference material consulted while preparing these pages.

Angular ships two ways to build forms. Template-driven forms keep the model in the template and suit small, simple forms; reactive forms declare an explicit, strongly typed model in the component class and scale to complex, dynamic, and heavily validated forms. See Forms.

Template-driven forms

Import FormsModule. ngModel binds a field to a property, ngForm is applied automatically to every <form> and tracks the aggregate state, and ngModelGroup nests a set of fields. Every control needs a name.

import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-signup',
  imports: [FormsModule],
  templateUrl: './signup.html',
})
export class Signup {
  model = { email: '', address: { city: '', zip: '' } };

  submit(form: NgForm): void {
    if (form.valid) {
      console.log(this.model);
    }
  }
}
<form #form="ngForm" (ngSubmit)="submit(form)">
  <input name="email" [(ngModel)]="model.email" required email #email="ngModel" />
  @if (email.invalid && email.touched) {
    <p class="error">A valid email is required.</p>
  }

  <fieldset ngModelGroup="address">
    <input name="city" [(ngModel)]="model.address.city" required />
    <input name="zip" [(ngModel)]="model.address.zip" pattern="\d{5}" />
  </fieldset>

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

[(ngModel)] is the "banana-in-a-box" shorthand for [ngModel] plus (ngModelChange). Export a control as a local template variable (#email="ngModel") to read its valid / touched / errors in the template. See Template-driven forms.

Reactive forms

Import ReactiveFormsModule. Build the model from FormControl, FormGroup, and FormArray, then bind it with [formGroup], formControlName, formGroupName, and formArrayName.

import { Component } from '@angular/core';
import {
  FormControl, FormGroup, FormArray,
  ReactiveFormsModule, Validators,
} from '@angular/forms';

@Component({
  selector: 'app-profile',
  imports: [ReactiveFormsModule],
  templateUrl: './profile.html',
})
export class Profile {
  readonly form = new FormGroup({
    name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
    email: new FormControl('', {
      nonNullable: true,
      validators: [Validators.required, Validators.email],
    }),
    address: new FormGroup({
      street: new FormControl('', { nonNullable: true }),
      city: new FormControl('', { nonNullable: true }),
    }),
    tags: new FormArray<FormControl<string>>([]),
  });

  addTag(): void {
    this.form.controls.tags.push(new FormControl('', { nonNullable: true }));
  }

  save(): void {
    if (this.form.valid) {
      console.log(this.form.getRawValue());
    }
  }
}
<form [formGroup]="form" (ngSubmit)="save()">
  <input formControlName="name" />

  <fieldset formGroupName="address">
    <input formControlName="street" />
    <input formControlName="city" />
  </fieldset>

  <div formArrayName="tags">
    @for (tag of form.controls.tags.controls; track $index) {
      <input [formControlName]="$index" />
    }
  </div>
  <button type="button" (click)="addTag()">Add tag</button>
</form>

FormBuilder

FormBuilder is a terser factory. Its nonNullable variant applies \{ nonNullable: true } to every control in the group.

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

private readonly fb = inject(FormBuilder);

readonly form = this.fb.nonNullable.group({
  name: ['', Validators.required],
  email: ['', [Validators.required, Validators.email]],
  address: this.fb.nonNullable.group({
    street: [''],
    city: [''],
  }),
  tags: this.fb.array<string>([]),
});

Strictly typed forms

Since Angular 14 reactive forms are typed by default. form.value is a partial view of the model (disabled controls are omitted), while form.getRawValue() returns the complete type. form.controls.name is a FormControl<string | null> unless you pass \{ nonNullable: true }, which changes the type to string and makes reset() restore the initial value instead of null. Reach for UntypedFormGroup / UntypedFormControl only while migrating an old codebase. See Typed forms.

Reading and reacting to state

Every AbstractControl exposes a snapshot and a matching stream:

Member Meaning

value / valueChanges

current value / Observable that emits on every change

status / statusChanges

VALID / INVALID / PENDING / DISABLED, and its stream

dirty / pristine

whether the user has changed the value

touched / untouched

whether the control has been blurred

errors

the validation-errors map, or null

import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime } from 'rxjs';

constructor() {
  this.form.controls.email.valueChanges
    .pipe(debounceTime(300), takeUntilDestroyed())
    .subscribe((email) => this.lookup(email));
}

// Or expose the status as a signal:
readonly status = toSignal(this.form.statusChanges, { initialValue: this.form.status });

Write values with setValue (needs the complete shape), patchValue (accepts a partial), and reset (clears to the initial value or null and restores pristine / untouched). Change a group’s shape at runtime with addControl / removeControl / setControl, and a FormArray with push / insert / removeAt. See AbstractControl.

Validation

Built-in Validators: required, requiredTrue, min, max, minLength, maxLength, pattern, email, and Validators.compose. Attach them at construction or later with setValidators / addValidators, followed by updateValueAndValidity().

Custom synchronous validator

A ValidatorFn receives a control and returns an errors object or null.

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

export function forbiddenName(pattern: RegExp): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null =>
    pattern.test(control.value) ? { forbiddenName: { value: control.value } } : null;
}

// name: new FormControl('', [Validators.required, forbiddenName(/admin/i)])

Cross-field validation

Put the validator on the group so it can compare siblings.

const passwordsMatch: ValidatorFn = (group: AbstractControl): ValidationErrors | null => {
  const pw = group.get('password')?.value;
  const confirm = group.get('confirm')?.value;
  return pw === confirm ? null : { passwordsMismatch: true };
};

readonly form = new FormGroup({
  password: new FormControl('', { nonNullable: true }),
  confirm: new FormControl('', { nonNullable: true }),
}, { validators: passwordsMatch });

Asynchronous validator

An AsyncValidatorFn returns a Promise or Observable that completes with the errors object or null; the control’s status stays PENDING until it settles.

import { AsyncValidatorFn } from '@angular/forms';
import { map, first } from 'rxjs';

export function uniqueEmail(api: UserApi): AsyncValidatorFn {
  return (control) =>
    api.exists(control.value).pipe(
      map((taken) => (taken ? { emailTaken: true } : null)),
      first(),
    );
}

// email: new FormControl('', {
//   validators: [Validators.required, Validators.email],
//   asyncValidators: [uniqueEmail(inject(UserApi))],
// })

CSS state classes

Angular mirrors control state onto the host element as classes — ng-valid / ng-invalid, ng-pristine / ng-dirty, ng-touched / ng-untouched, and ng-pending — so error styling needs no template logic:

input.ng-invalid.ng-touched {
  border-color: crimson;
}

Custom form controls

Implement ControlValueAccessor so a component participates in formControlName / ngModel like a native input, and register it through the NG_VALUE_ACCESSOR multi-provider.

import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-star-rating',
  template: `
    @for (star of stars; track star) {
      <button type="button" (click)="pick(star)" [disabled]="disabled()">
        {{ star <= value() ? '★' : '☆' }}
      </button>
    }
  `,
  providers: [
    { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
  ],
})
export class StarRating implements ControlValueAccessor {
  readonly stars = [1, 2, 3, 4, 5];
  readonly value = signal(0);
  readonly disabled = signal(false);

  private onChange: (v: number) => void = () => {};
  private onTouched: () => void = () => {};

  writeValue(v: number): void { this.value.set(v ?? 0); }
  registerOnChange(fn: (v: number) => void): void { this.onChange = fn; }
  registerOnTouched(fn: () => void): void { this.onTouched = fn; }
  setDisabledState(isDisabled: boolean): void { this.disabled.set(isDisabled); }

  pick(v: number): void {
    this.value.set(v);
    this.onChange(v);
    this.onTouched();
  }
}

Dynamic forms

Build controls from data — for example a field schema fetched from the server — by iterating a config array and assembling a FormGroup at runtime, then rendering it with @for over the same config. See Dynamic forms.

interface FieldConfig { key: string; label: string; required?: boolean; }

buildForm(config: FieldConfig[]): FormGroup {
  const group: Record<string, FormControl<string>> = {};
  for (const field of config) {
    group[field.key] = new FormControl('', {
      nonNullable: true,
      validators: field.required ? [Validators.required] : [],
    });
  }
  return new FormGroup(group);
}

Experimental: Signal Forms

Angular is developing Signal Forms (@angular/forms/signals), a signal-native API where the form model is a signal and validation is declared against it rather than attached to control instances. It is experimental, its surface may change, and it should not be used in production yet — track its progress at the Signal Forms guide.

The reactive-form model tree

graph TD FG["FormGroup — form"] FG --> NAME["FormControl — name"] FG --> EMAIL["FormControl — email"] FG --> ADDR["FormGroup — address"] ADDR --> STREET["FormControl — street"] ADDR --> CITY["FormControl — city"] FG --> TAGS["FormArray — tags"] TAGS --> T0["FormControl — index 0"] TAGS --> T1["FormControl — index 1"]

A FormGroup keys its children by name; a FormArray indexes them; both can nest arbitrarily. value, status, dirty, and touched aggregate upward from the leaves to the root control. Cross-links: Signals for toSignal bridging, HTTP Client for submitting form data, and Error Handling for reporting failed submissions.