Angular Signal Forms: Build Forms with Signals in Angular 22

Angular Tutorial: Beginner to Advanced · 16. Advanced forms, state and RxJS · 7 min read · Updated

Angular now has three ways to build forms. Template-driven and reactive forms are the classic ones. Signal forms are the newest: your form data lives in a signal, and the form is built around it. If you like signals, this is the most natural option, and it needs less boilerplate for many forms.

In Angular 22.2, the signal forms API is marked as public API, and the Angular team continues to evolve it, so read the release notes when you upgrade. Reactive forms remain fully supported and are the safe choice for existing code.

What you will learn

  • How to turn a signal model into a form with form()
  • How to bind inputs with formField and the form with formRoot
  • How to add built-in and custom validation, and submit

The idea

  1. You have a model: a signal holding a plain object.
  2. form(model, schema) creates a field tree that mirrors the model. Each field has signals such as value(), valid(), invalid(), touched(), errors().
  3. A schema function declares validation rules for each field path.
  4. In the template, [formField]="form.name" connects an input to a field, and [formRoot]="form" connects the whole <form>.

The model is the single source of truth. When the user types, the model signal changes. When your code sets the model, the inputs update.

A sign-up form

// src/app/sigforms/signup.ts
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { FormField, FormRoot, form, email, minLength, required, validate } from '@angular/forms/signals';

interface SignupModel {
  name: string;
  email: string;
  password: string;
  confirm: string;
  accept: boolean;
}

@Component({
  selector: 'app-signup',
  imports: [FormField, FormRoot, JsonPipe],
  template: `
    <form [formRoot]="signupForm">
      <p>
        <label for="name">Name</label><br />
        <input id="name" [formField]="signupForm.name" />
        @if (signupForm.name().touched() && signupForm.name().invalid()) {
          <span role="alert">{{ signupForm.name().errors()[0].message }}</span>
        }
      </p>
      <p>
        <label for="email">Email</label><br />
        <input id="email" type="email" [formField]="signupForm.email" />
        @if (signupForm.email().touched() && signupForm.email().invalid()) {
          <span role="alert">{{ signupForm.email().errors()[0].message }}</span>
        }
      </p>
      <p>
        <label for="password">Password</label><br />
        <input id="password" type="password" [formField]="signupForm.password" />
      </p>
      <p>
        <label for="confirm">Confirm password</label><br />
        <input id="confirm" type="password" [formField]="signupForm.confirm" />
        @if (signupForm.confirm().touched() && signupForm.confirm().invalid()) {
          <span role="alert">{{ signupForm.confirm().errors()[0].message }}</span>
        }
      </p>
      <p><label><input type="checkbox" [formField]="signupForm.accept" /> I accept the terms</label></p>
      <button type="submit" [disabled]="signupForm().submitting()">Sign up</button>
    </form>
    @if (done()) {
      <p role="status">Welcome, {{ model().name }}!</p>
    }
    <p>Valid: {{ signupForm().valid() }}</p>
    <pre>{{ model() | json }}</pre>
  `,
})
export class Signup {
  protected readonly model = signal<SignupModel>({ name: '', email: '', password: '', confirm: '', accept: false });

  protected readonly done = signal(false);

  protected readonly signupForm = form(
    this.model,
    (path) => {
    required(path.name, { message: 'Name is required' });
    required(path.email, { message: 'Email is required' });
    email(path.email, { message: 'Enter a valid email' });
    minLength(path.password, 8, { message: 'Use at least 8 characters' });
    validate(path.confirm, ({ value, valueOf }) =>
      value() === valueOf(path.password) ? null : { kind: 'mismatch', message: 'Passwords do not match' },
    );
    },
    {
      submission: {
        // Runs only when the form is valid.
        action: async () => {
          await new Promise((resolve) => setTimeout(resolve, 50)); // pretend to call an API
          this.done.set(true);
          return undefined;
        },
      },
    },
  );
}

Walk through the key lines:

  • signal<SignupModel>({...}) is the data. Normal signal rules apply, so you can read model() anywhere.
  • form(this.model, (path) => {...}, {...}) builds the form. The callback gets path, a typed map of your fields, such as path.email.
  • required(path.name, { message: '...' }), email(...) and minLength(path.password, 8, ...) are built-in validators. Others include min, max, maxLength and pattern.
  • validate(path.confirm, ({ value, valueOf }) => ...) is a custom validator. It returns null when valid, or an error object with a kind and message. valueOf(path.password) reads another field, so this is also cross-field validation.
  • The third argument supplies submission.action, which runs only when the form is valid and receives the submit event from formRoot.
  • In the template, signupForm.email() returns the field state. Calling .touched(), .invalid() and .errors() reads its signals, so the page updates automatically.
  • signupForm().valid() (note the call on the root) is the validity of the whole form.
  • submitting() is true while the async action runs, which is handy for disabling the button.

Run it

// src/app/app.ts
import { Component } from '@angular/core';
import { Signup } from './sigforms/signup';

@Component({
  imports: [Signup],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Signal form</h1>
  <app-signup />
</main>

Try typing a bad email, tabbing away, and watching the message appear. Make the passwords differ, and the confirm field shows its error. Fill everything correctly, tick the box and press Sign up.

Tests

You can test a signal form like any component:

// src/app/sigforms/signup.spec.ts
import { TestBed } from '@angular/core/testing';
import { Signup } from './signup';

function type(el: HTMLInputElement, value: string) {
  el.value = value;
  el.dispatchEvent(new Event('input'));
  el.dispatchEvent(new Event('blur'));
}

describe('Signup (signal form)', () => {
  async function create() {
    const fixture = TestBed.createComponent(Signup);
    await fixture.whenStable();
    const el = fixture.nativeElement as HTMLElement;
    const input = (id: string) => el.querySelector<HTMLInputElement>('#' + id)!;
    return { fixture, el, input };
  }

  it('shows errors for an invalid email and mismatching passwords', async () => {
    const { fixture, el, input } = await create();
    type(input('email'), 'not-an-email');
    type(input('password'), 'longenough');
    type(input('confirm'), 'different');
    await fixture.whenStable();
    expect(el.textContent).toContain('Enter a valid email');
    expect(el.textContent).toContain('Passwords do not match');
  });

  it('does not submit an invalid form', async () => {
    const { fixture, el } = await create();
    el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
    await new Promise((r) => setTimeout(r, 100));
    await fixture.whenStable();
    expect(el.querySelector('[role=status]')).toBeNull();
  });

  it('submits a valid form', async () => {
    const { fixture, el, input } = await create();
    type(input('name'), 'Ada');
    type(input('email'), 'ada@example.com');
    type(input('password'), 'longenough');
    type(input('confirm'), 'longenough');
    el.querySelector<HTMLInputElement>('input[type=checkbox]')!.click();
    await fixture.whenStable();
    el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
    await new Promise((r) => setTimeout(r, 150));
    await fixture.whenStable();
    expect(el.querySelector('[role=status]')?.textContent).toContain('Welcome, Ada');
  });
});

Because the model is an ordinary signal, you can also test validation logic without a component, by setting values on the model and reading form.field().valid().

Compare the three approaches

  • Source of truth: Template-driven: The template (ngModel); Reactive: FormGroup objects; Signal forms: A signal model
  • Validation: Template-driven: HTML attributes, directives; Reactive: Validator functions; Signal forms: Schema functions with paths
  • Reading state: Template-driven: Template variables; Reactive: control.valid, valueChanges; Signal forms: Signals: field().valid()
  • Typing: Template-driven: Loose; Reactive: Typed forms; Signal forms: Types from the model
  • Best for: Template-driven: Tiny forms; Reactive: Complex, existing apps; Signal forms: New signal-based code

Which should you choose?

  • Existing project with reactive forms: keep them. They are stable and well documented.
  • New project built on signals: signal forms are worth trying, especially for forms driven by a data model.
  • Third-party controls written for reactive forms usually still work, because formField can bind to a ControlValueAccessor for backwards compatibility, though the docs recommend native inputs or signal-form custom controls.
  • Always check the official signal forms guide for new features before relying on a detail, because this area is newer than the rest of Angular.

Try it yourself

  1. Add a rule so the terms checkbox must be ticked, using validate on path.accept.
  2. Add a pattern validator to the name field that forbids digits.
  3. Show a summary of all errors at the top of the form using signupForm().errors().

Quick check

  1. What is the source of truth of a signal form?
  2. Which function declares validation rules?
  3. How does a custom validator say the value is valid?

Next, learn about state management with a signal store.

Frequently asked questions

Are signal forms ready for production?

They are public API in Angular 22, but newer than reactive forms, so expect the API to keep growing. Pin your Angular version, read release notes, and keep complex or critical forms on reactive forms if your team prefers stability.

Can I use ngModel with signals instead?

Yes. [(ngModel)] can bind to a signal, as shown earlier in this course, but signal forms add validation, touched state and submission in one model.