Signal forms are Angular's modern forms API: less boilerplate than reactive forms and fully signal-based.
Signal forms (stable in Angular 22, from @angular/forms/signals) build a form on top of a writable signal holding your data model:
model = signal({ email: '', password: '' });
loginForm = form(this.model, (p) => {
required(p.email, { message: 'Email is required' });
email(p.email);
minLength(p.password, 8);
});form() returns a field tree that mirrors the model's shape: loginForm.email, loginForm.password, and nested objects and arrays as deeper fields. Edits in the UI write straight into the model signal, so model() is always the current form value.
Bind controls with the `[formField]` directive (import FormField): <input [formField]="loginForm.email" />. It syncs value, disabled and touched state for native inputs, and custom controls can opt in.
Each field is callable and returns its state as signals: loginForm.email().value(), valid(), invalid(), errors(), touched(), dirty(), pending(). The root loginForm() aggregates everything.
The schema function also supports pattern, min/max, custom validate(path, fn), async validateHttp, conditional required(path, { when }), disabled(path, fn) and hidden(...).
For submission, submit(form, action) marks all fields touched, runs the async action only if the form is valid, tracks submitting(), and maps server errors you return back onto fields. Reactive forms (FormGroup) remain fully supported for existing code.
Example
import { Component, signal } from '@angular/core';
import { form, FormField, required, email, minLength, 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()) {
@for (e of loginForm.email().errors(); track e.kind) { <small>{{ e.message ?? e.kind }}</small> }
}
<input type="password" [formField]="loginForm.password" /> <button>Log in</button>
</form>`,
})
export class Login {
protected model = signal({ email: '', password: '' });
protected loginForm = form(this.model, (p) => {
required(p.email, { message: 'Email is required' });
email(p.email, { message: 'Not a valid email' });
minLength(p.password, 8);
});
protected async onSubmit(event: Event) {
event.preventDefault();
await submit(this.loginForm, async () => { console.log('sending', this.model()); return undefined; });
}
}Remember
form(modelSignal, schemaFn)builds a typed field tree that writes into the model- Bind inputs with
[formField]; read state viafield().valid(),errors(),touched() - Validators (
required,email,minLength,validate...) are declared in the schema submit()validates, tracks submitting and applies server errors
Watch out for
- Keeping a separate copy of the data and syncing it with the form; the model signal already is the form value.