Signal forms

Angular · Practical · 15 min

Signal forms are Angular's modern forms API: less boilerplate than reactive forms and fully signal-based.

Signal forms (stable in Angular 22, from @angular/forms/signals) build a form on top of a writable signal holding your data model:

model = signal({ email: '', password: '' });
loginForm = form(this.model, (p) => {
  required(p.email, { message: 'Email is required' });
  email(p.email);
  minLength(p.password, 8);
});

form() returns a field tree that mirrors the model's shape: loginForm.email, loginForm.password, and nested objects and arrays as deeper fields. Edits in the UI write straight into the model signal, so model() is always the current form value.

Bind controls with the `[formField]` directive (import FormField): <input [formField]="loginForm.email" />. It syncs value, disabled and touched state for native inputs, and custom controls can opt in.

Each field is callable and returns its state as signals: loginForm.email().value(), valid(), invalid(), errors(), touched(), dirty(), pending(). The root loginForm() aggregates everything.

The schema function also supports pattern, min/max, custom validate(path, fn), async validateHttp, conditional required(path, { when }), disabled(path, fn) and hidden(...).

For submission, submit(form, action) marks all fields touched, runs the async action only if the form is valid, tracks submitting(), and maps server errors you return back onto fields. Reactive forms (FormGroup) remain fully supported for existing code.

Example

import { Component, signal } from '@angular/core';
import { form, FormField, required, email, minLength, submit } from '@angular/forms/signals';
@Component({
  selector: 'app-login', imports: [FormField],
  template: `
    <form (submit)="onSubmit($event)">
      <input type="email" [formField]="loginForm.email" />
      @if (loginForm.email().touched() && loginForm.email().invalid()) {
        @for (e of loginForm.email().errors(); track e.kind) { <small>{{ e.message ?? e.kind }}</small> }
      }
      <input type="password" [formField]="loginForm.password" /> <button>Log in</button>
    </form>`,
})
export class Login {
  protected model = signal({ email: '', password: '' });
  protected loginForm = form(this.model, (p) => {
    required(p.email, { message: 'Email is required' });
    email(p.email, { message: 'Not a valid email' });
    minLength(p.password, 8);
  });
  protected async onSubmit(event: Event) {
    event.preventDefault();
    await submit(this.loginForm, async () => { console.log('sending', this.model()); return undefined; });
  }
}

Remember

  • form(modelSignal, schemaFn) builds a typed field tree that writes into the model
  • Bind inputs with [formField]; read state via field().valid(), errors(), touched()
  • Validators (required, email, minLength, validate...) are declared in the schema
  • submit() validates, tracks submitting and applies server errors

Watch out for

  • Keeping a separate copy of the data and syncing it with the form; the model signal already is the form value.

Check yourself

  1. With signal forms, where is the current value of the whole form?

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

Official docs: angular.dev/guide/forms/signals/models