Signal Forms is Angular’s new forms system, built on signals. It appeared experimentally in v21 and became a stable public API in v22. The core idea: you start from a model (a signal with your data), and form() builds on top of it a tree of fields with state (valid, touched, errors…) and rules defined in a schema.
| Reactive Forms | Signal Forms | |
|---|---|---|
| Source of truth | FormGroup (internal copy) | your model (signal) |
| Structure | built by hand (FormControl, FormGroup) | inferred from the model’s type |
| Validation | validators per control | schema: required(p.email), validate(...) |
| Reactivity | valueChanges (Observable) | signals (value(), valid()) |
| Binding | formControlName | [formField] |
| Typing | good (typed forms, v14+) | complete, from the model |
The first form
import { Component, signal } from '@angular/core';
import { FormField, FormRoot, email, form, minLength, required } from '@angular/forms/signals';
interface Login {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="loginForm">
<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 }}</small> }
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().submitting()">Sign in</button>
</form>
`,
})
export class LoginForm {
// 1. THE MODEL: an ordinary signal — the source of truth
readonly model = signal<Login>({ email: '', password: '' });
// 2. THE FORM: a tree of fields over the model + the SCHEMA of rules
readonly loginForm = form(
this.model,
(path) => {
required(path.email, { message: 'Email is required' });
email(path.email, { message: 'Invalid email' });
required(path.password);
minLength(path.password, 8, { message: 'At least 8 characters' });
},
{ submission: { action: async (f) => this.auth.login(f().value()) } },
);
}The model IS the form
// The model and the form are the SAME thing — there is no internal copy
this.model.set({ email: 'ana@atlas.dev', password: '' }); // → the input updates
this.loginForm.email().value.set('x@y.ro'); // → model() updates
// This means:
// - you load data from the server with model.set(...)
// - you can derive anything with computed(() => this.model().email)
// - you no longer have patchValue / setValue / valueChangesThe state of a field
// Each node of the tree is a function: call it and you get the state (signals)
const email = this.loginForm.email(); // FieldState<string>
email.value(); // WritableSignal<string> — bound directly to the model
email.value.set('a@b.ro');
email.valid(); email.invalid(); email.pending();
email.touched(); email.dirty();
email.errors(); // ValidationError[] — { kind, message }
email.getError('email');
email.disabled(); email.hidden(); email.readonly(); email.required();
email.markAsTouched();
email.focus(); // v21.1: focus on the bound control
// The whole form is a field too
this.loginForm().valid();
this.loginForm().submitting();
this.loginForm().errorSummary(); // all the errors in the tree
this.loginForm().reset();When to show the errors
A field can be invalid long before the user is done with it: ana@ is not an email yet. The field state gives you everything you need to pick the moment: dirty() (the value changed), touched() (the user has left the field) and valid().
dirty() && invalid(): the message appears at the first keystroke.
| password | ||
|---|---|---|
dirty() | false | false |
touched() | false | false |
valid() | false | false |
| error on screen | false | false |
- Keystrokes
- 0
- Keystrokes with an error showing
- 0
Showing errors while the user types (dirty) scolds them for a word they haven't finished. Waiting for touched is the usual default: the first verdict comes when they leave the field, and from then on the message updates live, so it disappears the moment the value is fixed. Waiting for submit suits short forms; a submit attempt marks every field as touched. Whatever you choose, put it in one place — a component like <app-field-errors> — not in every template.
Validators
import { email, max, maxLength, min, minLength, pattern, required, validate } from '@angular/forms/signals';
form(this.model, (p) => {
required(p.name, { message: 'Required' });
minLength(p.name, 2);
maxLength(p.bio, 280);
email(p.email);
min(p.age, 18, { message: 'At least 18 years old' });
max(p.age, 120);
pattern(p.zip, /^\d{6}$/, { message: 'Postal code of 6 digits' });
required(p.terms); // on a boolean: false = invalid (like a native checkbox)
// conditional
required(p.company, { when: ({ valueOf }) => valueOf(p.type) === 'business' });
// custom, between fields
validate(p.confirm, ({ value, valueOf }) =>
value() !== valueOf(p.password) ? { kind: 'mismatch', message: 'Passwords don’t match' } : undefined,
);
});Submitting the form
// Option 1: submission in the form() options + <form [formRoot]>
readonly f = form(this.model, schemaFn, {
submission: {
action: async (field) => {
try {
await this.api.register(field().value());
return undefined; // success
} catch {
// server errors tied to a specific field
return { kind: 'taken', message: 'Email already in use', fieldTree: field.email };
}
},
onInvalid: (field) => field().errorSummary()[0]?.fieldTree().focus(), // focus on the first error
},
});
// Option 2: a manual submit()
async save() {
await submit(this.f, async (field) => this.api.save(field().value()));
}What you can bind with [formField]
<!-- [formField] works on all the native controls: -->
<input [formField]="f.name" /> <!-- string -->
<input type="number" [formField]="f.age" /> <!-- number | null -->
<input type="checkbox" [formField]="f.terms" /> <!-- boolean -->
<input type="date" [formField]="f.birthday" /> <!-- string / Date -->
<select [formField]="f.country">...</select>
<textarea [formField]="f.bio"></textarea>
<input type="radio" value="a" [formField]="f.plan" />
<!-- and on custom controls: FormValueControl / FormCheckboxControl / ControlValueAccessor -->
<app-rating-control [formField]="f.rating" />Conditional fields, dynamic lists and async validation are covered in Signal Forms — advanced; your own controls, in Custom form controls.
When YES, when NO
- Any new form in a v22 application.
- Forms with a lot of conditional logic (hidden fields, disabled fields, validations that depend on other fields).
- When you want the data to be a simple signal, easy to derive and save.
- A large application with hundreds of working reactive forms — migrate gradually, not all at once.
- Component libraries that only support
ControlValueAccessor+ Reactive Forms (check the CVA interop). - The team needs
valueChangesas an Observable everywhere (althoughtoObservablesolves it).
Common pitfalls
- You forget to call the field:
f.email.valid()doesn’t exist; the right form isf.email().valid()(first the state, then the signal). - Errors shown immediately: show them only after
touched()(or after a submit). Submitting automatically marks everything as touched. - Mutating the model:
model().email = 'x'doesn’t notify. Usemodel.update()orf.email().value.set(). min/maxwith strings: from v22 they accept only numbers (breaking).