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 tree of signals representing field values plus a small API around it. The most basic case — a login form:
import { Component, signal } from '@angular/core';
import { form, field, required, minLength, email } from '@angular/forms/signals';
@Component({
selector: 'app-login',
template: `
<form (submit)="submit($event)">
<input [value]="loginForm.email().value()" (input)="loginForm.email().value.set($any($event.target).value)" />
@if (loginForm.email().errors().required) { <p>Email required</p> }
@if (loginForm.email().errors().email) { <p>Invalid email</p> }
<input type="password" [value]="loginForm.password().value()" (input)="loginForm.password().value.set($any($event.target).value)" />
@if (loginForm.password().errors().minLength) { <p>At least 8 characters</p> }
<button type="submit" [disabled]="!loginForm.valid()">Sign in</button>
</form>
`,
})
export class LoginComponent {
loginForm = form({
email: field('', [required(), email()]),
password: field('', [required(), minLength(8)]),
});
submit(e: Event) {
e.preventDefault();
if (this.loginForm.valid()) {
this.auth.login(this.loginForm.value());
}
}
}
Three mechanical facts:
form()takes a schema — an object mapping field names tofield()declarations- Every field becomes a signal — read with
(), value sub-signal with.value(), errors with.errors() - The whole form is reactive —
loginForm.valid(),loginForm.value(),loginForm.dirty()are all signals
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(schema) |
Build a form tree from a field schema |
field<T>(initial, validators?) |
Declare one form field with an initial value and validators |
array<T>(initial, itemSchema) |
Declare a repeatable list of fields |
required() / email() / minLength(n) / maxLength(n) / pattern(re) / min(n) / max(n) |
Built-in validators |
| `custom((value) => error | null)` |
asyncValidator(fn) |
Async validation (e.g. unique-username check) |
All validators are first-class functions you compose into the field declaration's second argument. No Validators.required static-method namespace.
Reading form state #
Every field exposes a typed shape:
const f = this.loginForm.email();
// f.value() -> string (signal)
// f.errors() -> { required?: true, email?: true }
// f.valid() -> boolean (true if no errors)
// f.touched() -> boolean (true after first blur)
// f.dirty() -> boolean (true if user has changed value)
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:
this.loginForm.set({ email: 'a@b.c', password: 'secret123' });
this.loginForm.reset(); // back to initial values, clears dirty/touched
Custom validators #
The shape — a function that takes the field value and returns an error object or null:
import { field, required, custom } from '@angular/forms/signals';
const passwordsMatch = custom((value: string, ctx) => {
return value === ctx.form.password().value() ? null : { mismatch: true };
});
confirmPassword = field('', [required(), passwordsMatch]);
The ctx parameter gives you the broader form tree for cross-field validation. The returned object's keys appear in .errors().
Async validation #
For server-side checks (username available, email not in use):
import { asyncValidator } from '@angular/forms/signals';
const uniqueUsername = asyncValidator(async (value: string) => {
if (!value) return null;
const taken = await fetch(`/api/users/check?name=${value}`).then(r => r.json());
return taken ? { taken: true } : null;
});
username = field('', [required(), minLength(3), uniqueUsername]);
The field exposes .pending() while the async validator is running:
@if (form.username().pending()) { <span>Checking...</span> }
Async validators are debounced automatically — typing fast doesn't fire the server check on every keystroke.
Field arrays #
For a list of repeated fields (todo items, addresses, line items):
import { form, field, array, required } from '@angular/forms/signals';
this.invoiceForm = form({
customer: field('', [required()]),
items: array<{ name: string; qty: number }>(
[{ name: '', qty: 1 }],
{
name: field('', [required()]),
qty: field(1, [min(1)]),
},
),
});
// Add/remove
this.invoiceForm.items().add({ name: '', qty: 1 });
this.invoiceForm.items().remove(0);
this.invoiceForm.items().count(); // signal — current array length
In the template:
@for (item of invoiceForm.items().controls(); track $index) {
<div>
<input [value]="item.name().value()" (input)="item.name().value.set($any($event.target).value)" />
<input type="number" [value]="item.qty().value()" (input)="item.qty().value.set(+$any($event.target).value)" />
</div>
}
Far cleaner than the FormArray of legacy reactive forms.
Wiring to native inputs #
The [value]="f.x().value()" (input)="f.x().value.set(...)" boilerplate adds up. The standard pattern is to wrap it in a tiny directive or input wrapper component. Lesson 4.5 covers form input wrappers in depth; for now, the verbose-but-explicit binding above works.
For third-party components (date pickers, custom selects), use the same two-way binding pattern — (input) becomes whatever event the component emits.
Comparing the three forms eras #
| Aspect | Template-driven | Reactive forms | Signal forms |
|---|---|---|---|
| Define controls in | Template ([(ngModel)]) |
Class (new FormGroup) |
Class (form({...})) |
| Validators | Directives (required, minlength) |
Functions (Validators.required) |
Functions (required()) |
| 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 |
|---|---|---|
form.field() reads undefined |
Field name typo or schema missing the field | Check the schema key matches |
| Validator runs but error doesn't display | Reading .errors() without the key |
.errors().required not .errors().requiredError |
| Form value out of sync with input | Native <input> [value] is one-way; need (input) to write back |
Always pair [value]=... with (input)=... |
| Async validator fires too often | Default debouncing should handle it; check that the field's value really is stable | Verify with effect(() => console.log(form.x().value())) |
form.set(...) throws |
Provided value doesn't match the schema shape | The whole-form set requires every field to have a value matching its type |
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 } from '@angular/core';
import { form, field, required, email, minLength, custom } from '@angular/forms/signals';
@Component({
selector: 'app-contact',
template: `
<form (submit)="send($event)">
<input placeholder="Email" [value]="f.email().value()" (input)="f.email().value.set($any($event.target).value)" />
<input placeholder="Confirm email" [value]="f.confirm().value()" (input)="f.confirm().value.set($any($event.target).value)" />
@if (f.confirm().errors().mismatch) { <p>Emails don't match</p> }
<textarea placeholder="Message" [value]="f.msg().value()" (input)="f.msg().value.set($any($event.target).value)"></textarea>
<button type="submit" [disabled]="!f.valid()">Send</button>
</form>
`,
})
export class ContactComponent {
f = form({
email: field('', [required(), email()]),
confirm: field('', [required(), custom((v, ctx) => v === ctx.form.email().value() ? null : { mismatch: true })]),
msg: field('', [required(), minLength(10)]),
});
send(e: Event) {
e.preventDefault();
if (this.f.valid()) console.log('Sending:', this.f.value());
}
}
Three fields, cross-field validation, type-safe values — and 20 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.


