Angular Signal Forms Tutorial (v22)

Build a real form with Angular's new Signal Forms API in v22. Define state as a signal, bind fields with formField, add validators, handle submit, and work with nested and array fields. Every snippet runs on @angular/core 22.0.0.

Share
Angular Signal Forms Tutorial (v22)

If you have built forms in Angular, you know the two old paths. Template-driven forms are quick but loose with types. Reactive forms are powerful but come with a pile of ceremony: FormControl, FormGroup, valueChanges subscriptions you have to remember to unsubscribe, and a ControlValueAccessor every time you want a custom input to play nice.

Signal Forms, stable in Angular v22, take a different route. You define your form state as a plain signal, wrap it with form(), and bind fields in the template with [formField]. Validity, errors, touched state, and values all come back as signals you read with (). No subscriptions, no ControlValueAccessor, full type inference from your model.

This tutorial walks the whole thing end to end. We build a real checkout-style form, add built-in and custom validators, wire up submit with a disabled-while-invalid button, then cover nested groups and array fields. Every snippet here is verified against @angular/core 22.0.0 and the companion code repo for the book. Let me show you how it fits together.

If you want the wider v22 picture first, see What's New in Angular v22. This article zooms into forms.

Live demo: every snippet in this post runs in the interactive Signal Forms demo, no setup needed.

What is the Signal Forms API in Angular v22?

Signal Forms live in @angular/forms/signals (a separate entry point from the classic @angular/forms). The core pieces you will use most:

Piece What it does
form(model, schema) Creates the form from a writable signal and an optional validation schema
[formField] directive Two-way binds a DOM control to a field; imported as FormField
required, email, min, max, minLength, maxLength, pattern Built-in validators applied inside the schema
validate(path, fn) Adds a custom validator that returns an error object or null
Field signals: .value(), .valid(), .invalid(), .touched(), .errors() Read field and form state reactively
submit(form, action) Runs a submit action, marks fields touched, blocks when invalid

The mental model: your signal holds the data, form() wraps it into a reactive field tree, and every field exposes its own signals. The model is the single source of truth, and the form stays in sync with it in both directions.

Step 1: Define form state as a signal

Start with a plain writable signal that describes the shape of your form. The types you put here flow through the entire form, so you get autocomplete and compile errors for free.

import { signal } from '@angular/core';

formModel = signal({
  method: '',
  amount: 100,
});

That is the whole "model" layer. No FormGroup, no nested builder calls yet, just an object literal inside a signal.

Step 2: Create the form with form()

Wrap the model with form(). The second argument is a schema callback where you declare validation against typed paths on the model.

import { Component, signal, inject } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';

@Component({
  selector: 'app-checkout-form',
  imports: [FormField],
  template: `...`,
})
export class CheckoutFormComponent {
  formModel = signal({ method: '', amount: 100 });

  checkoutForm = form(this.formModel, (schema) => {
    required(schema.method, { message: 'Please select a payment method.' });
    required(schema.amount, { message: 'Amount is required.' });
  });
}

schema.method and schema.amount are typed paths, not strings. If you typo a field name, the build fails. required takes an optional config object with a message, which is exactly what you display to the user later.

checkoutForm is now a field tree. You can read the whole form's state with checkoutForm() and individual fields with checkoutForm.method.

Step 3: Bind fields with [formField]

In the template, bind each control with the [formField] directive, pointing at the matching field. This handles value binding and status updates in both directions, so the underlying signal stays current as the user types or selects.

<form (submit)="onSubmit(); $event.preventDefault()">
  <label for="payment-method">Select Payment Method</label>
  <select id="payment-method" [formField]="checkoutForm.method">
    <option value="">Choose a method...</option>
    <option value="credit">Credit Card</option>
    <option value="paypal">PayPal</option>
  </select>

  <label for="amount">Amount ($)</label>
  <input id="amount" type="number" [formField]="checkoutForm.amount" />

  <button type="submit" [disabled]="checkoutForm().invalid()">
    Pay {{ checkoutForm.amount().value() | currency }}
  </button>
</form>

A few things worth noticing:

  • [formField]="checkoutForm.method" binds a native <select> directly. No ControlValueAccessor, no formControlName.
  • checkoutForm.amount().value() reads the current field value as a signal, so the button label updates live.
  • checkoutForm().invalid() reads the whole form's validity. We use it to disable submit.

Remember to add FormField to the component's imports array, since standalone components only see directives they import.

Step 4: Display validation errors with @if

Each field exposes .invalid(), .touched(), and .errors() as signals. The common pattern is to show errors only after the user has touched the field, then loop the error list. Errors carry a kind and an optional message.

<select id="payment-method" [formField]="checkoutForm.method">
  <option value="">Choose a method...</option>
  <option value="credit">Credit Card</option>
  <option value="paypal">PayPal</option>
</select>

@if (checkoutForm.method().invalid() && checkoutForm.method().touched()) {
  <div class="error-msg">
    @for (error of checkoutForm.method().errors(); track error.kind) {
      <span>{{ error.message }}</span>
    }
  </div>
}

track error.kind is the right key here because each validator contributes one error kind (required, email, your custom kind, and so on). No subscriptions, no markAsTouched calls scattered through your component. The touched state is tracked for you and exposed as a signal.

Step 5: Add a custom validator

Built-in validators cover the common cases, but real forms always need custom rules. Use validate(path, fn). The function receives a context with the field's value signal and a valueOf helper to read other fields. Return an error object with a kind and message, or return null when the field is valid.

import { form, required, validate } from '@angular/forms/signals';

checkoutForm = form(this.formModel, (schema) => {
  required(schema.method, { message: 'Please select a payment method.' });

  // Custom rule: amount must be a positive number under the limit.
  validate(schema.amount, ({ value }) => {
    const amount = value();
    if (amount <= 0) {
      return { kind: 'minAmount', message: 'Amount must be greater than zero.' };
    }
    if (amount > 10000) {
      return { kind: 'maxAmount', message: 'Amount cannot exceed $10,000.' };
    }
    return null;
  });
});

There is no customError() helper. A custom error is just a plain object with kind and an optional message. That object flows into the field's .errors() array, so the same @for (error of ...; track error.kind) template renders it with zero extra work.

Cross-field validation

Because the validator context gives you valueOf, cross-field rules are straightforward. Reading another field with valueOf is reactive, so the rule re-runs when that field changes. A classic "passwords must match" check:

import { form, required, validate } from '@angular/forms/signals';

signupForm = form(this.signupModel, (schema) => {
  required(schema.password);
  required(schema.confirmPassword);

  validate(schema.confirmPassword, ({ value, valueOf }) => {
    if (value() !== valueOf(schema.password)) {
      return { kind: 'passwordMismatch', message: 'Passwords do not match.' };
    }
    return null;
  });
});

Step 6: Handle submit and disable while invalid

You already disabled the submit button with [disabled]="checkoutForm().invalid()". For the submit handler itself, guard on validity and read the typed value off the form. The value comes back fully typed from your original model.

import { Component, signal, inject } from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { PaymentService } from './payment.service';

export class CheckoutFormComponent {
  private paymentService = inject(PaymentService);
  paymentResult = signal<{ success: boolean; transactionId: string } | null>(null);

  formModel = signal({ method: '', amount: 100 });
  checkoutForm = form(this.formModel, (schema) => {
    required(schema.method);
    required(schema.amount);
  });

  onSubmit() {
    if (this.checkoutForm().valid()) {
      const { method, amount } = this.checkoutForm().value();
      const result = this.paymentService.processPayment(method, amount);
      this.paymentResult.set(result);
    }
  }
}

For more advanced flows, Signal Forms also ship a submit() function from @angular/forms/signals. It runs an async action, marks fields touched, and refuses to run while the form is invalid or while a submit is already in progress. The manual onSubmit above is fine for simple cases; reach for submit() when you want that built-in lifecycle handling for server calls.

This full checkout example, including an accessible accordion built with Angular Aria, lives in the book's code repo at apps/chapter-06/src/app/checkout-form.

Nested and grouped fields

Nested objects need no special API. Nest them in the model and the field tree mirrors the shape. You bind nested fields with dotted paths and validate them on nested schema paths.

formModel = signal({
  customer: {
    name: '',
    email: '',
  },
  amount: 100,
});

checkoutForm = form(this.formModel, (schema) => {
  required(schema.customer.name, { message: 'Name is required.' });
  required(schema.customer.email, { message: 'Email is required.' });
  email(schema.customer.email, { message: 'Enter a valid email.' });
});
<input [formField]="checkoutForm.customer.name" />
<input [formField]="checkoutForm.customer.email" />

@if (checkoutForm.customer.email().invalid() && checkoutForm.customer.email().touched()) {
  <div class="error-msg">
    @for (error of checkoutForm.customer.email().errors(); track error.kind) {
      <span>{{ error.message }}</span>
    }
  </div>
}

email is one of the built-in validators alongside min, max, minLength, maxLength, and pattern, all imported from @angular/forms/signals. Each accepts the same { message } config so you control the copy.

Array fields

For repeating rows, hold an array in your model and validate each item with applyEach. It applies a schema to every item in the array, and inside that item schema you validate against the item's own paths.

import { form, required, applyEach } from '@angular/forms/signals';

orderModel = signal({
  items: [
    { name: '', quantity: 1 },
  ],
});

orderForm = form(this.orderModel, (schema) => {
  applyEach(schema.items, (item) => {
    required(item.name, { message: 'Item name is required.' });
    required(item.quantity);
  });
});

In the template, iterate the field tree's items with @for and bind each row. To add or remove rows, update the source signal with .update(); the field tree reacts automatically.

@for (item of orderForm.items; track $index) {
  <div class="row">
    <input [formField]="item.name" />
    <input type="number" [formField]="item.quantity" />
  </div>
}

<button type="button" (click)="addItem()">Add item</button>
addItem() {
  this.orderModel.update((model) => ({
    ...model,
    items: [...model.items, { name: '', quantity: 1 }],
  }));
}

Because the array is just data in a signal, you mutate it with the immutable update patterns you already use for signals. There is no FormArray API to learn separately.

Signal Forms vs Reactive Forms

Both are first-class in v22, so this is a choice, not a migration mandate. Here is the honest comparison.

Concern Reactive Forms Signal Forms
State source FormControl / FormGroup instances A plain signal you already understand
Reading values valueChanges observable or .value .value() signal, no subscription
Validity .valid / statusChanges .valid() / .invalid() signals
Custom inputs ControlValueAccessor boilerplate [formField] binds directly
Type safety Typed forms, but verbose generics Inferred from the model object
Async / RxJS interop Native, mature Newer; resource-based async validation

What Signal Forms remove is most striking in two places: the ControlValueAccessor dance for custom controls, and the manual subscription management around valueChanges. If your reactivity is already signal-first, Signal Forms keep you in one mental model end to end.

When to still use Reactive Forms

Be pragmatic. Reach for Reactive Forms when:

  • You have a large existing codebase already on Reactive Forms and a rewrite is not worth it.
  • You lean heavily on RxJS operators over valueChanges (debounced server search, complex stream composition). That interop is more mature in the classic API today.
  • A third-party library you depend on expects ControlValueAccessor or FormGroup instances.

For new forms in new code, Signal Forms are the cleaner default in v22. For everything else, migrate when the value is clear, not because the API is new. If your wider goal is moving a reactive codebase toward signals, the RxJS to Signals migration guide covers the patterns, and the NgRx to Signals guide covers state management.

Common pitfalls

A few things that trip people up early:

  • Wrong import path. Signal Forms come from @angular/forms/signals, not @angular/forms. Mixing them up is the most common first error.
  • Forgetting to import FormField. Standalone components must list it in imports, or [formField] silently does nothing.
  • Showing errors before touch. Gate error display on .touched() so the form does not scream at users on first paint. Pair .invalid() with .touched().
  • Mutating the model in place. Update the source signal immutably with .set() or .update(). In-place mutation will not trigger the reactivity you expect.
  • Expecting a customError() helper. There is none. Custom validators return a plain { kind, message } object or null.
  • Reaching for FormArray. Arrays are just arrays in your signal; use applyEach for per-item validation.

Frequently asked questions

Is Signal Forms stable in Angular v22?

Yes. Signal Forms graduated to stable in Angular v22. They were introduced in v21 as a preview and stabilized in v22 alongside Angular Aria, with full documentation and Angular Material and Angular Aria support added.

What is the import path for Signal Forms?

Everything comes from @angular/forms/signals: form, FormField, required, validate, email, min, max, minLength, maxLength, pattern, applyEach, and submit. This is a separate entry point from the classic @angular/forms.

How do I add a custom validator in Signal Forms?

Use validate(path, fn) inside the schema callback. The function returns a plain error object with a kind and optional message, or null when valid. Read the field's own value with the context's value() signal and other fields with valueOf(...).

How do I read form values and validity?

They are signals. Read a field value with myForm.fieldName().value(), field validity with .valid() / .invalid(), touched state with .touched(), and errors with .errors(). The whole form's validity is myForm().valid() / myForm().invalid().

Do Signal Forms replace Reactive Forms?

No. Both are supported in v22. Signal Forms are the cleaner default for new, signal-first code. Reactive Forms remain a solid choice for existing codebases and heavy RxJS-over-valueChanges workflows.

How do I handle array (repeating) fields?

Hold an array in your model signal and apply a per-item schema with applyEach(schema.items, (item) => { ... }). Render rows with @for, bind each with [formField], and add or remove rows by updating the source signal immutably.

Go deeper

This tutorial is the working core of Signal Forms, but a production form has more: accessible structure with Angular Aria, async validation backed by resource, and how forms fit into a fully signal-driven component. Chapter 6 of Mastering Angular Signals builds the complete checkout form end to end, pairing Signal Forms with Angular Aria headless directives for keyboard and screen-reader support, and connecting it to signal-based services with the new @Service() decorator. Every example matches the v22 code repo.

The book is written by a Google Developer Expert in AI and Angular, with a foreword from the Angular team. Start on Leanpub, where you get the PDF and EPUB and free updates: https://leanpub.com/mastering-angular-signals. It is also on Amazon. Full details on the book landing page.

Mastering Angular Signals
Master Angular Signals, updated for v22 — Signal Forms, httpResource, zoneless, and a practical RxJS/NgRx migration. From core concepts to advanced patterns.