Angular now has three ways to build forms. Template-driven and reactive forms are the classic ones. Signal forms are the newest: your form data lives in a signal, and the form is built around it. If you like signals, this is the most natural option, and it needs less boilerplate for many forms.
In Angular 22.2, the signal forms API is marked as public API, and the Angular team continues to evolve it, so read the release notes when you upgrade. Reactive forms remain fully supported and are the safe choice for existing code.
What you will learn
- How to turn a signal model into a form with
form() - How to bind inputs with
formFieldand the form withformRoot - How to add built-in and custom validation, and submit
The idea
- You have a model: a signal holding a plain object.
form(model, schema)creates a field tree that mirrors the model. Each field has signals such asvalue(),valid(),invalid(),touched(),errors().- A schema function declares validation rules for each field path.
- In the template,
[formField]="form.name"connects an input to a field, and[formRoot]="form"connects the whole<form>.
The model is the single source of truth. When the user types, the model signal changes. When your code sets the model, the inputs update.
A sign-up form
// src/app/sigforms/signup.ts
import { JsonPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { FormField, FormRoot, form, email, minLength, required, validate } from '@angular/forms/signals';
interface SignupModel {
name: string;
email: string;
password: string;
confirm: string;
accept: boolean;
}
@Component({
selector: 'app-signup',
imports: [FormField, FormRoot, JsonPipe],
template: `
<form [formRoot]="signupForm">
<p>
<label for="name">Name</label><br />
<input id="name" [formField]="signupForm.name" />
@if (signupForm.name().touched() && signupForm.name().invalid()) {
<span role="alert">{{ signupForm.name().errors()[0].message }}</span>
}
</p>
<p>
<label for="email">Email</label><br />
<input id="email" type="email" [formField]="signupForm.email" />
@if (signupForm.email().touched() && signupForm.email().invalid()) {
<span role="alert">{{ signupForm.email().errors()[0].message }}</span>
}
</p>
<p>
<label for="password">Password</label><br />
<input id="password" type="password" [formField]="signupForm.password" />
</p>
<p>
<label for="confirm">Confirm password</label><br />
<input id="confirm" type="password" [formField]="signupForm.confirm" />
@if (signupForm.confirm().touched() && signupForm.confirm().invalid()) {
<span role="alert">{{ signupForm.confirm().errors()[0].message }}</span>
}
</p>
<p><label><input type="checkbox" [formField]="signupForm.accept" /> I accept the terms</label></p>
<button type="submit" [disabled]="signupForm().submitting()">Sign up</button>
</form>
@if (done()) {
<p role="status">Welcome, {{ model().name }}!</p>
}
<p>Valid: {{ signupForm().valid() }}</p>
<pre>{{ model() | json }}</pre>
`,
})
export class Signup {
protected readonly model = signal<SignupModel>({ name: '', email: '', password: '', confirm: '', accept: false });
protected readonly done = signal(false);
protected readonly signupForm = form(
this.model,
(path) => {
required(path.name, { message: 'Name is required' });
required(path.email, { message: 'Email is required' });
email(path.email, { message: 'Enter a valid email' });
minLength(path.password, 8, { message: 'Use at least 8 characters' });
validate(path.confirm, ({ value, valueOf }) =>
value() === valueOf(path.password) ? null : { kind: 'mismatch', message: 'Passwords do not match' },
);
},
{
submission: {
// Runs only when the form is valid.
action: async () => {
await new Promise((resolve) => setTimeout(resolve, 50)); // pretend to call an API
this.done.set(true);
return undefined;
},
},
},
);
}
Walk through the key lines:
signal<SignupModel>({...})is the data. Normal signal rules apply, so you can readmodel()anywhere.form(this.model, (path) => {...}, {...})builds the form. The callback getspath, a typed map of your fields, such aspath.email.required(path.name, { message: '...' }),email(...)andminLength(path.password, 8, ...)are built-in validators. Others includemin,max,maxLengthandpattern.validate(path.confirm, ({ value, valueOf }) => ...)is a custom validator. It returnsnullwhen valid, or an error object with akindandmessage.valueOf(path.password)reads another field, so this is also cross-field validation.- The third argument supplies
submission.action, which runs only when the form is valid and receives the submit event fromformRoot. - In the template,
signupForm.email()returns the field state. Calling.touched(),.invalid()and.errors()reads its signals, so the page updates automatically. signupForm().valid()(note the call on the root) is the validity of the whole form.submitting()is true while the asyncactionruns, which is handy for disabling the button.
Run it
// src/app/app.ts
import { Component } from '@angular/core';
import { Signup } from './sigforms/signup';
@Component({
imports: [Signup],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
<h1>Signal form</h1>
<app-signup />
</main>
Try typing a bad email, tabbing away, and watching the message appear. Make the passwords differ, and the confirm field shows its error. Fill everything correctly, tick the box and press Sign up.
Tests
You can test a signal form like any component:
// src/app/sigforms/signup.spec.ts
import { TestBed } from '@angular/core/testing';
import { Signup } from './signup';
function type(el: HTMLInputElement, value: string) {
el.value = value;
el.dispatchEvent(new Event('input'));
el.dispatchEvent(new Event('blur'));
}
describe('Signup (signal form)', () => {
async function create() {
const fixture = TestBed.createComponent(Signup);
await fixture.whenStable();
const el = fixture.nativeElement as HTMLElement;
const input = (id: string) => el.querySelector<HTMLInputElement>('#' + id)!;
return { fixture, el, input };
}
it('shows errors for an invalid email and mismatching passwords', async () => {
const { fixture, el, input } = await create();
type(input('email'), 'not-an-email');
type(input('password'), 'longenough');
type(input('confirm'), 'different');
await fixture.whenStable();
expect(el.textContent).toContain('Enter a valid email');
expect(el.textContent).toContain('Passwords do not match');
});
it('does not submit an invalid form', async () => {
const { fixture, el } = await create();
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
await new Promise((r) => setTimeout(r, 100));
await fixture.whenStable();
expect(el.querySelector('[role=status]')).toBeNull();
});
it('submits a valid form', async () => {
const { fixture, el, input } = await create();
type(input('name'), 'Ada');
type(input('email'), 'ada@example.com');
type(input('password'), 'longenough');
type(input('confirm'), 'longenough');
el.querySelector<HTMLInputElement>('input[type=checkbox]')!.click();
await fixture.whenStable();
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
await new Promise((r) => setTimeout(r, 150));
await fixture.whenStable();
expect(el.querySelector('[role=status]')?.textContent).toContain('Welcome, Ada');
});
});
Because the model is an ordinary signal, you can also test validation logic without a component, by setting values on the model and reading form.field().valid().
Compare the three approaches
- Source of truth: Template-driven: The template (ngModel); Reactive:
FormGroupobjects; Signal forms: A signal model - Validation: Template-driven: HTML attributes, directives; Reactive: Validator functions; Signal forms: Schema functions with paths
- Reading state: Template-driven: Template variables; Reactive:
control.valid,valueChanges; Signal forms: Signals:field().valid() - Typing: Template-driven: Loose; Reactive: Typed forms; Signal forms: Types from the model
- Best for: Template-driven: Tiny forms; Reactive: Complex, existing apps; Signal forms: New signal-based code
Which should you choose?
- Existing project with reactive forms: keep them. They are stable and well documented.
- New project built on signals: signal forms are worth trying, especially for forms driven by a data model.
- Third-party controls written for reactive forms usually still work, because
formFieldcan bind to aControlValueAccessorfor backwards compatibility, though the docs recommend native inputs or signal-form custom controls. - Always check the official signal forms guide for new features before relying on a detail, because this area is newer than the rest of Angular.
Try it yourself
- Add a rule so the terms checkbox must be ticked, using
validateonpath.accept. - Add a
patternvalidator to the name field that forbids digits. - Show a summary of all errors at the top of the form using
signupForm().errors().
Quick check
- What is the source of truth of a signal form?
- Which function declares validation rules?
- How does a custom validator say the value is valid?
Next, learn about state management with a signal store.