Angular Signal Forms
Build type-safe, reactive forms with Angular's Signal Forms API: a signal model is the single
source of truth, the form structure is derived from it, and a schema function declares validation
and field state. Signal Forms are public API in Angular v22 (imported from @angular/forms/signals).
For exhaustive API depth, read the angular-developer skill's canonical reference at
../angular-developer/references/signal-forms.md
(vendored from Angular's official skill and corrected locally to the stable v22 API – see the local adaptations in 02-SKILLS.md before re-syncing it). The references below are the project-shaped fast path.
End-to-end example
import { Component, inject, signal } from '@angular/core';
import { form, FormField, required, email, submit } from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [FormField],
template: `
<form (submit)="onSubmit($event)">
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<input type="password" [formField]="loginForm.password" />
<button [disabled]="loginForm().invalid()">Login</button>
</form>
`,
})
export class Login {
private readonly auth = inject(AuthService);
// Model signal is the single source of truth.
protected readonly loginModel = signal({ email: '', password: '' });
protected readonly loginForm = form(this.loginModel, (path) => {
required(path.email, { message: 'Email is required' });
email(path.email, { message: 'Enter a valid email address' });
required(path.password, { message: 'Password is required' });
});
onSubmit(event: Event) {
event.preventDefault();
// submit() marks all fields touched, then runs the async action only if valid.
submit(this.loginForm, async () => {
await this.auth.login(this.loginModel());
});
}
}
Critical rules
| Rule |
Correct |
| Call the field to reach its state signals. |
form.field().valid(), not form.field.valid() |
The model is the source of truth – never .set() the field tree; update the model signal. |
this.model.update(...), not form.field.set(...) |
Forbidden [formField] attributes – these are handled by the directive or by schema rules. |
Never set value/min/max/[disabled]/[readonly] |
submit() callback must be async and return a Promise. |
submit(form, async () => { ... }) |
| Schema paths are not signals – don't call them; read other fields via context helpers. |
valueOf(path.x) / stateOf(path.x), not path.x() |
Every component runs on the default OnPush + zoneless (v22+). |
Do not set changeDetection; never opt into Eager |
Which reference to load
| Task |
Reference |
| Set up a form, read/update model values, display errors, style, reset |
references/build-a-form.md |
| Read field-level or form-level state (valid, touched, dirty, pending…) |
references/field-state.md |
| Add validation (built-in, debounce, conditional, custom, cross-field, async) |
references/validation.md |
| Conditional fields, submission, arrays / dynamic fields |
references/dynamic-forms.md |
Build a custom form control (FormValueControl) |
references/custom-controls.md |
| Maintain or migrate an existing Reactive Forms area |
references/reactive-forms.md |
| Full API surface, common pitfalls, build-error recovery |
../angular-developer/references/signal-forms.md |
Verify – zero errors before done
Run from the repo root and confirm no errors:
pnpm exec tsc --noEmit (or pnpm build)
pnpm lint
There is no nx here – do not run nx lint.
1---2name: ng-forms3description: Build signal-based forms in Angular v22+ using the Signal Forms API (public API since v22, imported from @angular/forms/signals). Use for form creation with automatic two-way binding, schema-based validation, field state management, and dynamic forms. Triggers on form implementation, adding validation, creating multi-step forms, or building forms with conditional fields. Don't use for template-driven forms without signals or third-party form libraries like Formly or ngx-formly.4license: MIT5---67# Angular Signal Forms89Build type-safe, reactive forms with Angular's Signal Forms API: a signal model is the single10source of truth, the form structure is derived from it, and a schema function declares validation11and field state. Signal Forms are public API in Angular v22 (imported from `@angular/forms/signals`).1213For exhaustive API depth, read the angular-developer skill's canonical reference at14[../angular-developer/references/signal-forms.md](../angular-developer/references/signal-forms.md)15(vendored from Angular's official skill and corrected locally to the stable v22 API – see the local adaptations in `02-SKILLS.md` before re-syncing it). The references below are the project-shaped fast path.1617## End-to-end example1819```typescript20import { Component, inject, signal } from '@angular/core';21import { form, FormField, required, email, submit } from '@angular/forms/signals';2223@Component({24 selector: 'app-login',25 imports: [FormField],26 template: `27 <form (submit)="onSubmit($event)">28 <input type="email" [formField]="loginForm.email" />29 @if (loginForm.email().touched() && loginForm.email().invalid()) {30 <p class="error">{{ loginForm.email().errors()[0].message }}</p>31 }32 <input type="password" [formField]="loginForm.password" />33 <button [disabled]="loginForm().invalid()">Login</button>34 </form>35 `,36})37export class Login {38 private readonly auth = inject(AuthService);3940 // Model signal is the single source of truth.41 protected readonly loginModel = signal({ email: '', password: '' });4243 protected readonly loginForm = form(this.loginModel, (path) => {44 required(path.email, { message: 'Email is required' });45 email(path.email, { message: 'Enter a valid email address' });46 required(path.password, { message: 'Password is required' });47 });4849 onSubmit(event: Event) {50 event.preventDefault();51 // submit() marks all fields touched, then runs the async action only if valid.52 submit(this.loginForm, async () => {53 await this.auth.login(this.loginModel());54 });55 }56}57```5859## Critical rules6061| Rule | Correct |62| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------- |63| **Call the field** to reach its state signals. | `form.field().valid()`, not `form.field.valid()` |64| **The model is the source of truth** – never `.set()` the field tree; update the model signal. | `this.model.update(...)`, not `form.field.set(...)` |65| **Forbidden `[formField]` attributes** – these are handled by the directive or by schema rules. | Never set `value`/`min`/`max`/`[disabled]`/`[readonly]` |66| **`submit()` callback must be `async`** and return a Promise. | `submit(form, async () => { ... })` |67| **Schema paths are not signals** – don't call them; read other fields via context helpers. | `valueOf(path.x)` / `stateOf(path.x)`, not `path.x()` |68| **Every component runs on the default `OnPush` + zoneless (v22+).** | Do not set `changeDetection`; never opt into `Eager` |6970## Which reference to load7172| Task | Reference |73| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |74| Set up a form, read/update model values, display errors, style, reset | [references/build-a-form.md](references/build-a-form.md) |75| Read field-level or form-level state (valid, touched, dirty, pending…) | [references/field-state.md](references/field-state.md) |76| Add validation (built-in, debounce, conditional, custom, cross-field, async) | [references/validation.md](references/validation.md) |77| Conditional fields, submission, arrays / dynamic fields | [references/dynamic-forms.md](references/dynamic-forms.md) |78| Build a custom form control (`FormValueControl`) | [references/custom-controls.md](references/custom-controls.md) |79| Maintain or migrate an existing Reactive Forms area | [references/reactive-forms.md](references/reactive-forms.md) |80| Full API surface, common pitfalls, build-error recovery | [../angular-developer/references/signal-forms.md](../angular-developer/references/signal-forms.md) |8182## Verify – zero errors before done8384Run from the repo root and confirm no errors:8586- `pnpm exec tsc --noEmit` (or `pnpm build`)87- `pnpm lint`8889There is no nx here – do not run `nx lint`.