Ch. 4 · Angular

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readadvancedupdated Oct 19, 2026

Angular has had two form systems for a decade. Template-driven forms put ngModel on inputs and let the framework build the model behind your back. Reactive forms make you build a tree of FormControl and FormGroup objects in TypeScript, each holding its own copy of the value. Signal Forms are a third design, built on signals, and they start from a different premise: your data model is a signal you already own, and the form is a set of views onto it.

Version matters here, more than for most topics. Signal Forms arrived in Angular 21 as an experimental API and the current API reference marks form, submit and the validators stable from v22. They don’t exist in Angular 19 or 20, so if an interviewer at a company on those versions asks about them, the honest answer includes “we’d need to upgrade first”. Early experimental builds used different binding names, so blog posts from 2025 may show syntax that no longer compiles. This guide uses the v22 API, imported from @angular/forms/signals.

The model is the source of truth

You start with a plain writable signal, then pass it to form():

interface Signup {
  email: string;
  password: string;
  confirm: string;
  isBusiness: boolean;
  company: string;
}

readonly model = signal<Signup>({
  email: '', password: '', confirm: '', isBusiness: false, company: '',
});

readonly signup = form(this.model);
TypeScript

signup is a FieldTree. Its shape mirrors the model, so signup.email and signup.company are fields, and nested objects and arrays become nested fields. Calling a field as a function gives you its state: signup.email().value(), .valid(), .errors(), .touched(), .dirty(), .disabled(), .pending(). Calling the root, signup(), gives the same signals for the whole form.

The important part is that form() doesn’t copy the model. Setting signup.email().value.set('ada@example.com') writes into this.model, and this.model.update(...) is immediately visible through the fields. In reactive forms, the form holds its own value and you sync it with patchValue and valueChanges; here there’s nothing to sync. If an interviewer asks what problem Signal Forms solve, “two copies of the same data drifting apart” is a good first sentence.

In the template, the FormField directive connects an input to a field:

<input type="email" [formField]="signup.email" />
<input type="password" [formField]="signup.password" />
HTML

It handles two-way binding and also reflects field state such as disabled, readonly and required onto the element.

Validation lives in one schema function

Rules go in a function passed as the second argument to form(). It receives a path object that mirrors the model, and you call rule functions against those paths:

readonly signup = form(this.model, (p) => {
  required(p.email, { message: 'Enter your email' });
  email(p.email, { message: 'That email looks incomplete' });

  required(p.password);
  minLength(p.password, 12, { message: 'Use at least 12 characters' });

  validate(p.confirm, ({ value, valueOf }) =>
    value() === valueOf(p.password) ? null : { kind: 'mismatch', message: 'Passwords do not match' },
  );

  required(p.company, {
    when: ({ valueOf }) => valueOf(p.isBusiness),
    message: 'Company is required for business accounts',
  });
  disabled(p.company, ({ valueOf }) => !valueOf(p.isBusiness));
});
TypeScript

A few things to notice compared with reactive forms.

Cross-field rules don’t need a group validator. The confirm rule reads valueOf(p.password), and because that read is reactive, the rule reruns when either field changes. The stale-sibling problem that pushes reactive-forms code onto the parent FormGroup doesn’t arise.

Conditional rules are declarative. required(..., { when }) and disabled(p.company, predicate) are re-evaluated whenever isBusiness changes. There’s no valueChanges subscription calling enable() and disable().

Errors are objects with a kind and an optional message, so the template can loop over field().errors() and print messages without a lookup table.

A worked walkthrough of that form

The schema above was run on Angular 22 in a zoneless TestBed, logging each field’s errors after each change. Here’s what happened, step by step:

  1. Empty form. The form is invalid. email has one error, required: Enter your email; the email() rule doesn’t also complain about an empty string, so there’s no duplicate message. password has a required error with no custom message. company has no errors and disabled() is true, because isBusiness is false.
  2. Typing ada@example, a 13-character password and a confirmation one character short. email is now error-free, which surprises people: like Validators.email, the built-in rule accepts an address with no dot in the domain. confirm shows mismatch: Passwords do not match.
  3. Fixing the confirmation and finishing the email. The form is valid, and logging model() shows every value already in the signal you own.
  4. model.update(m => ({ ...m, isBusiness: true })). Changing the model directly, not the field, re-ran the conditional rules: company became enabled and gained required: Company is required for business accounts.
  5. Filling in the company. Valid again.

One more detail from that run: after setting values programmatically, email().dirty() was still false and touched() was false until markAsTouched() was called. Dirty and touched track user interaction through the bound control, which is what you want when deciding whether to show an error.

Submitting and server errors

Client rules can’t know that an email is already registered. The submit() helper runs your async action and lets it return errors aimed at specific fields:

async onSubmit(event: Event) {
  event.preventDefault();
  await submit(this.signup, async (f) => {
    const result = await this.accounts.register(this.model());
    if (result.status === 'email-taken') {
      return [{ fieldTree: f.email, kind: 'server', message: 'An account with this email already exists' }];
    }
    return undefined;
  });
}
TypeScript

In the test run, submit resolved to false, signup.email().errors() contained the server message and the form was invalid. As soon as the email value changed, the server error cleared and the form was valid again, so the user isn’t stuck with a stale “already exists” message. submit also refuses to run twice concurrently for the same form, which removes a common double-submit bug.

Signal Forms or reactive forms?

For a new app on Angular 22 that already uses signals for state, Signal Forms remove a lot of glue: no value copies, no valueChanges subscriptions for conditional logic, types inferred from the model. For an existing app with hundreds of reactive forms, a rewrite buys little. Reactive forms are still supported, and the Angular docs say plainly that they remain a solid choice. There’s a @angular/forms/signals/compat entry point for gradual interop, but mixing two form systems in one form is rarely worth the confusion.

Things to check before choosing: whether your component library’s custom inputs support the signal-based control interface yet, whether you depend on ControlValueAccessor-based third-party controls, and whether the team is comfortable with signals in general.

Practice question: the conditional address

“A checkout form has a ‘ship to a different address’ checkbox. When ticked, five address fields appear and are required; when unticked, they must not block submission. How would you do it in reactive forms, and how in Signal Forms?”

Answer. In reactive forms I’d put the address controls in a nested FormGroup, subscribe to the checkbox’s valueChanges, and call addressGroup.enable() or disable(). Disabled controls skip validation and drop out of form.value, so required errors stop blocking submission, but I’d have to remember to unsubscribe and to set the initial state on load, and I’d use getRawValue() if I ever needed the hidden values. In Signal Forms the model holds shipElsewhere: boolean and an address object. In the schema I’d write applyWhen(p.address, ({ valueOf }) => valueOf(p.shipElsewhere), (a) => { required(a.line1); required(a.city); ... }), or disabled(p.address, ...) with the inverse condition, and the template wraps the fields in @if (signup.shipElsewhere().value()). The rules follow the checkbox automatically with no subscription. Either way the server must re-check: if shipElsewhere is false it should ignore any address fields it receives.

More in Angular

read ✓Angular · mid

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readread →
read ✓Angular · hard

Angular Resource API for Async Data

How resource() turns a signal-driven request into value, status and error signals, cancels stale loads, and why value() can throw on error.

~6 min readread →
read ✓Angular · mid

Angular Template-Driven vs Reactive Forms

Both form styles build the same FormControl objects. What differs is who creates them and when, which changes timing, typing and testing.

~5 min readread →
esc