Modern Angular: Signals, Standalone Components and SSR · Components, Signals and Data

Signal Forms: Models, Validation and Submission

Build forms with Angular Signal Forms: a signal model, schema validation rules, field state in templates, cross-field validation and submission with server errors.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A sign-up form built with Signal Forms: the form's data is a signal model, a schema adds required, email, minLength and a cross-field "passwords match" rule, the template binds inputs with [formField], and submission maps a server error ("email already registered") back onto the email field.

The idea

  • form(modelSignal, schemaFn) creates a field tree mirroring the model; each field (f.email) exposes state such as value(), errors(), valid(), touched().
  • Schema rules: required, email, min, minLength, pattern, and validate(path, ctx => error | undefined) for custom and cross-field rules (ctx.valueOf(otherPath)).
  • [formField]="f.email" binds an input to a field both ways.
  • submit(form, action) marks fields touched, runs the action only if valid, and applies errors the action returns to the matching fields.
  • Signal Forms are stable in Angular 22; Reactive Forms (FormBuilder) remain supported for existing code.

The code

A sign-up form with Signal Forms
import { Component, signal } from "@angular/core";
import { FormField, email, form, minLength, required, submit, validate } from "@angular/forms/signals";
import { TestBed } from "@angular/core/testing";
import { BrowserTestingModule, platformBrowserTesting } from "@angular/platform-browser/testing";

type SignUp = { email: string; password: string; confirm: string };

@Component({
  selector: "app-signup",
  imports: [FormField],
  template: `
    <form (submit)="save($event)">
      <input id="email" [formField]="f.email" />
      <input id="password" type="password" [formField]="f.password" />
      <input id="confirm" type="password" [formField]="f.confirm" />
      @for (field of [f.email, f.password, f.confirm]; track $index) {
        @for (error of field().errors(); track error.kind) { <p class="error">{{ error.message }}</p> }
      }
      <button type="submit">Create account</button>
      <p class="status">{{ status() }}</p>
    </form>
  `,
})
class SignUpForm {
  model = signal<SignUp>({ email: "", password: "", confirm: "" });
  f = form(this.model, (path) => {
    required(path.email, { message: "Email is required" });
    email(path.email, { message: "Enter a valid email" });
    minLength(path.password, 10, { message: "Password needs at least 10 characters" });
    validate(path.confirm, ({ value, valueOf }) =>
      value() === valueOf(path.password) ? undefined : { kind: "mismatch", message: "Passwords don't match" });
  });
  status = signal("");

  async save(event: Event) {
    event.preventDefault();
    const ok = await submit(this.f, async (form) => {
      // A real app calls its API here; this one pretends the email is taken.
      if (form.email().value() === "[email protected]") {
        return [{ fieldTree: form.email, kind: "server", message: "Email already registered" }];
      }
      return undefined;
    });
    this.status.set(ok ? `Account created for ${this.model().email}` : "");
  }
}

async function main() {
  TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
  const fixture = TestBed.createComponent(SignUpForm);
  const el: HTMLElement = fixture.nativeElement;
  const type = (id: string, value: string) => {
    const input = el.querySelector<HTMLInputElement>(`#${id}`)!;
    input.value = value;
    input.dispatchEvent(new Event("input"));
  };
  const submitForm = async (label: string) => {
    el.querySelector("button")!.click();
    await fixture.whenStable();
    await fixture.whenStable();
    const messages = [...el.querySelectorAll(".error, .status")].map((n) => n.textContent!.trim()).filter(Boolean);
    console.log(`${label}: ${messages.join(" / ")}`);
  };

  await submitForm("empty");
  type("email", "ana@"); type("password", "short"); type("confirm", "other");
  await submitForm("invalid");
  type("email", "[email protected]"); type("password", "correct-horse"); type("confirm", "correct-horse");
  await submitForm("taken email");
  type("email", "[email protected]");
  await submitForm("valid");
}
main();

Output

empty: Email is required
invalid: Enter a valid email / Password needs at least 10 characters / Passwords don't match
taken email: Email already registered
valid: Account created for [email protected]

Run this on your computer — see “Get Started” for setup.

Common mistakes

  • Keeping a separate copy of the form data and syncing it manually; the model signal is the data.
  • Cross-field rules implemented in the submit handler instead of validate with valueOf, so errors don't show on the field.
  • Showing server errors in a generic banner instead of returning them from submit onto the right field.
  • Trusting client-side validation; the server must validate again.

Key points

  • form(model, schema) creates a typed field tree from a signal.
  • Built-in rules plus validate() for custom and cross-field validation.
  • submit() validates, runs the action and maps server errors to fields.

Further reading: Signal Forms overview · Validation · Form submission

Practice questions

What does form(model, schema) return?

Answer

A field tree mirroring the model's shape; each field exposes reactive state such as value, errors, valid and touched, and binds to inputs with [formField].

How do you validate one field against another?

Answer

With validate(path, ctx => ...), reading the other field's value with ctx.valueOf(otherPath) and returning an error or undefined.

How are server-side errors shown on the right field?

Answer

The submit action returns errors with a fieldTree pointing at the field; submit applies them as validation errors on that field.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?