Angular Signal Forms Explained: The 2026 Forms API Replacing valueChanges + takeUntil [2026]
Angular's forms story has had three eras: template-driven (v2+), reactive forms with FormGroup/FormControl (v2+), and as of v22 — signal forms. The new API takes the lessons from the signal system (lesson 3.1) and applies them to form state. The result: no more valueChanges.pipe(takeUntil(destroy$)), no more setValue vs patchValue confusion, no more loose typing on controls. Just signals.
This is lesson 4.1 of the Angular Tutorial, opening Module 4. Even though it sits in a Beginner-friendly slot, it represents the modern shape of forms in 2026. Lessons 4.2 and 4.3 cover Reactive Forms and Template-Driven Forms for migration cases; this lesson is what you reach for in new code.
The shape #
A signal form is a model signal plus a field tree that form() derives from it. The most basic case — a login form:
import { Component, inject, signal } from '@angular/core';
import { form, FormField, FormRoot, required, minLength, email } from '@angular/forms/signals';
import { AuthService } from './auth.service';
@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 (error of loginForm.email().errors(); track error) { <p>{{ error.message }}</p> }
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().invalid()">Sign in</button>
</form>
`,
})
export class LoginComponent {
private auth = inject(AuthService);
loginModel = signal({ email: '', password: '' });
loginForm = form(
this.loginModel,
(s) => {
required(s.email, { message: 'Email required' });
email(s.email, { message: 'Invalid email' });
minLength(s.password, 8);
},
{ submission: { action: async (f) => { await this.auth.login(f().value()); } } },
);
}
Three mechanical facts:
form()takes a model signal and a schema function — the schema binds validators to paths likes.email- Every field is callable —
loginForm.email()returns its state:.value(),.errors(),.touched() - The whole form is reactive —
loginForm().valid(),loginForm().value(),loginForm().dirty()are all signals;[formField]syncs inputs both ways
Replaces the legacy FormGroup / FormControl / Validators trio with one cohesive API.
The primitives #
Signal forms ship from @angular/forms/signals:
| Primitive | What it does |
|---|---|
form(model, schema?, options?) |
Build a field tree from a model signal |
FormField ([formField]) |
Two-way binds an input to a field |
FormRoot ([formRoot]) |
Handles <form> submission |
required() / email() / minLength() / maxLength() / pattern() / min() / max() |
Built-in validators: minLength(s.password, 8) |
validate(path, fn) |
Custom validator returning { kind } or null |
validateHttp() / validateAsync() |
Async validation (e.g. unique-username check) |
applyEach(path, itemSchema) |
Rules for every array item |
All validators are plain functions you call inside the schema function, passing a path. No Validators.required static-method namespace.
Reading form state #
Every field exposes a typed shape:
const f = this.loginForm.email();
// f.value() -> string (writable signal)
// f.errors() -> ValidationError[] ([{ kind: 'required', message: '...' }])
// f.valid() -> boolean (no errors, nothing pending)
// f.touched() -> boolean (true after first blur)
// f.dirty() -> boolean (true if user has changed value)
The root of the field tree is a field too, so the whole form aggregates these:
this.loginForm().value(); // { email: string, password: string }
this.loginForm().valid(); // true if every field is valid
this.loginForm().dirty(); // true if any field is dirty
this.loginForm().touched(); // true if any field has been blurred
Everything is a signal. Templates auto-track. effect() can react. computed() can derive.
Writing field values #
Field values are writable signals — .value.set(...) and .value.update(...):
this.loginForm.email().value.set('new@example.com');
this.loginForm.password().value.update(v => v.trim());
For a whole-form update, write to the model signal:
this.loginModel.set({ email: 'a@b.c', password: 'secret123' });
this.loginForm().reset({ email: '', password: '' }); // sets the value, clears dirty/touched
Custom validators #
The shape — validate() with a function that returns an error object (kind, optional message) or null:
import { form, required, validate } from '@angular/forms/signals';
signupForm = form(this.signupModel, (s) => {
required(s.confirmPassword);
validate(s.confirmPassword, ({ value, valueOf }) =>
value() === valueOf(s.password)
? null
: { kind: 'mismatch', message: 'Passwords do not match' },
);
});
valueOf() reads another field by path — that's cross-field validation, re-run when either field changes. The error appears in .errors().
Async validation #
For server-side checks (username available, email not in use):
import { form, required, minLength, validateHttp } from '@angular/forms/signals';
signupForm = form(this.signupModel, (s) => {
required(s.username);
minLength(s.username, 3);
validateHttp(s.username, {
debounce: 300,
request: ({ value }) => (value() ? `/api/users/check?name=${value()}` : undefined),
onSuccess: (res: { taken: boolean }) =>
res.taken ? { kind: 'taken', message: 'Username is taken' } : null,
onError: () => ({ kind: 'serverError', message: 'Could not check username' }),
});
});
The field exposes .pending() while the request is running (validateHttp() needs provideHttpClient()):
@if (signupForm.username().pending()) { <span>Checking...</span> }
Async rules run only after the sync rules pass, and a new value cancels the in-flight request. Debouncing is opt-in via debounce: 300.
Field arrays #
Put an array in the model and the field tree mirrors it. Apply per-item rules with applyEach():
import { form, applyEach, required, min } from '@angular/forms/signals';
invoiceModel = signal({
customer: '',
items: [{ name: '', qty: 1 }],
});
invoiceForm = form(this.invoiceModel, (s) => {
required(s.customer);
applyEach(s.items, (item) => {
required(item.name);
min(item.qty, 1);
});
});
// Add/remove — update the model; the field tree follows
addItem() {
this.invoiceModel.update(m => ({ ...m, items: [...m.items, { name: '', qty: 1 }] }));
}
removeItem(index: number) {
this.invoiceModel.update(m => ({ ...m, items: m.items.filter((_, i) => i !== index) }));
}
In the template:
@for (item of invoiceForm.items; track item) {
<div>
<input [formField]="item.name" />
<input type="number" [formField]="item.qty" />
<button type="button" (click)="removeItem($index)">Remove</button>
</div>
}
Far cleaner than the FormArray of legacy reactive forms.
Wiring to native inputs #
[formField] does the wiring: on native inputs, textareas, selects and checkboxes it syncs the value, marks the field touched on blur, and mirrors attributes like disabled and required from your schema. Just add FormField to the component's imports.
For custom components (date pickers, custom selects), implement FormValueControl<T> — a value = model<T>() signal — and [formField] binds to them like a native input.
Comparing the three forms eras #
| Aspect | Template-driven | Reactive forms | Signal forms |
|---|---|---|---|
| Define controls in | Template ([(ngModel)]) |
Class (new FormGroup) |
Class (form(model, schema)) |
| Validators | Directives (required, minlength) |
Functions (Validators.required) |
Schema rules (required(s.email)) |
| State exposed as | Template variables (#ref="ngModel") |
Properties (form.get('x').value) |
Signals (form.x().value()) |
valueChanges for reactivity |
Observable | Observable | Signals + computed() |
| Cleanup on destroy | Automatic | Manual (takeUntil) |
Automatic (signals) |
| Type safety | Weak | Improved with FormGroup<T> (lesson 4.6) |
Strong, automatic |
| Best for in 2026 | Tiny prototypes | Legacy code maintenance | All new code |
For every new form in 2026: signal forms. Reactive forms remain valid; template-driven survives but is rarely the right call.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
loginForm.x is undefined |
Model has no x key, or it is undefined |
Initialize every field ('' or null) |
| Error never displays | Reading .errors().required |
.errors() is an array — check error.kind |
| Input doesn't sync | FormField missing from imports |
Add FormField to imports |
| Async validator fires on every keystroke | No debounce configured | Add debounce: 300 to validateHttp() |
| Validator throws when called | Called outside the schema function | Call validators only inside the function passed to form() |
What's next #
Lesson 4.2 covers Reactive Forms — the FormGroup/FormControl API for legacy code and gradual migration. Lesson 4.3 covers template-driven forms. Lesson 4.4 dives into validation patterns (cross-field, async). Lesson 4.5 walks file uploads with progress. Lesson 4.6 covers typed Reactive Forms.
Try it yourself #
A tiny contact form with cross-field validation:
import { Component, signal } from '@angular/core';
import { form, FormField, FormRoot, email, minLength, validate } from '@angular/forms/signals';
@Component({
selector: 'app-contact',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="f">
<input placeholder="Email" [formField]="f.email" />
<input placeholder="Confirm email" [formField]="f.confirm" />
@if (f.confirm().touched() && f.confirm().invalid()) { <p>{{ f.confirm().errors()[0].message }}</p> }
<textarea placeholder="Message" [formField]="f.msg"></textarea>
<button type="submit" [disabled]="f().invalid()">Send</button>
</form>
`,
})
export class ContactComponent {
model = signal({ email: '', confirm: '', msg: '' });
f = form(this.model, (s) => {
email(s.email);
validate(s.confirm, ({ value, valueOf }) =>
value() === valueOf(s.email) ? null : { kind: 'mismatch', message: "Emails don't match" },
);
minLength(s.msg, 10);
}, { submission: { action: async (field) => console.log('Sending:', field().value()) } });
}
Three fields, cross-field validation, type-safe values — 25 lines.
get_best_practicesYes for new forms; opportunistically for old ones. New forms get all the benefits — type safety, signal reactivity, less boilerplate. For existing reactive forms that work, leave them. When you next touch the file to add a feature, that’s the moment to migrate the whole form. Don’t do a big-bang migration — both APIs coexist, and signal forms can wrap reactive controls when needed.Lesson 4.2 picks up Reactive Forms — for legacy code and the cases where they still fit.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


