Angular 22 promotes signal forms to a stable API, and after moving a handful of real forms over to them I’m convinced they’re the way forward. They throw out most of the FormGroup/FormControl ceremony, give you a single typed signal as the source of truth, and put validation right next to the field it guards. This post walks through what they are, why they’re so much nicer than reactive forms, and how to upgrade an existing form one piece at a time.
A quick recap of the pain
Reactive forms have served Angular well for years, but they carry a lot of baggage:
- Two parallel worlds. Your data lives in an interface, but the form lives in a
FormGroupofFormControls. You’re forever mapping between the two —patchValueon the way in,.valueon the way out. - Weak typing. Typed reactive forms helped, but you still end up with
form.get('email')?.valuereturningstring | null | undefinedand a lot of non-null assertions. - Validation lives far from the field. Validators are passed positionally when you build the control, and conditional rules (“required only when X”) mean wiring up
valueChangessubscriptions and callingupdateValueAndValidity()by hand. - Template-driven forms are simpler to start with but scale badly — logic ends up smeared across the template and you lose type safety entirely.
Signal forms fix the root cause: there’s only one piece of state, and it’s a signal.
What a signal form looks like
You start with a plain signal holding your data, then wrap it with form():
import { Component, signal } from '@angular/core';
import { form, FormField, required, email, maxLength } from '@angular/forms/signals';
interface ProfileModel {
name: string;
email: string;
bio: string;
}
@Component({
selector: 'app-profile',
templateUrl: './profile.component.html',
imports: [FormField],
})
export class ProfileComponent {
// The single source of truth.
protected readonly model = signal<ProfileModel>({ name: '', email: '', bio: '' });
// The form wraps the model and declares validation in a schema.
protected readonly profileForm = form(this.model, (f) => {
required(f.name, { message: 'Name is required' });
required(f.email, { message: 'Email is required' });
email(f.email, { message: 'Enter a valid email' });
maxLength(f.bio, 500);
});
}
The form() call returns a field tree. Each field is a signal you call to read its state, and the schema function (the second argument) is where every validation rule lives.
In the template you bind with the [formField] directive — no formGroup/formControlName wiring:
<label for="name">Name</label>
<input id="name" [formField]="profileForm.name" />
<label for="email">Email</label>
<input id="email" type="email" [formField]="profileForm.email" />
<label for="bio">Bio</label>
<textarea id="bio" [formField]="profileForm.bio" rows="3"></textarea>
Reading state is just calling the field as a function:
this.profileForm.email().value(); // current value (typed as string)
this.profileForm.email().valid(); // boolean signal
this.profileForm.email().touched(); // has the user interacted?
this.profileForm.email().errors(); // ValidationError[]
this.profileForm().invalid(); // whole-form validity
Why they’re so good
After porting real forms, here’s what actually made a difference:
- One source of truth. The model signal is the form state. Bind it to inputs, read it back on submit, derive
computed()values from it — nopatchValue/.valueround-tripping. Set the signal and the UI updates; type in the UI and the signal updates. - Type safety for free. The field tree is generated from your model interface.
profileForm.email().value()is astring, notstring | null. Rename a property and the form breaks at compile time, not runtime. - Validation lives with the field. The schema reads top to bottom like a spec: this field is required, that one is capped at 500 chars. No hunting through
FormBuilderconfig. - Conditional rules are declarative. The thing that used to need a
valueChangessubscription is now awhenoption (example below). - It composes with signals. Forms are just signals, so they drop straight into
computed(),effect(), andOnPush/zoneless change detection withoutasyncpipes or manual subscriptions. - Less template noise. One
[formField]per input replacesformGroup+formControlName+ a separate validation-message component.
Conditional validation: the part that used to hurt
Here’s a pattern straight out of production: a “reason” dropdown plus a notes field that is only required when the reason is “Other.” In reactive forms this meant subscribing to valueChanges and toggling validators. In signal forms it’s one declarative when:
interface CloseModel {
reasonId: number | null;
note: string;
}
protected readonly model = signal<CloseModel>({ reasonId: null, note: '' });
protected readonly closeForm = form(this.model, (f) => {
required(f.reasonId, { message: 'Please select a reason' });
maxLength(f.note, 2000);
required(f.note, {
message: 'Please specify the reason',
when: ({ valueOf }) => valueOf(f.reasonId) === REASON_OTHER,
});
});
No subscription, no updateValueAndValidity(), no cleanup. The rule re-evaluates automatically because it’s reading a signal.
Showing one error at a time
The one piece of glue worth writing yourself is a small helper that mirrors the classic “show the first error, but only after the field is touched” behavior. Field errors are just signals, so the helper is tiny:
import { Signal } from '@angular/core';
import type { ValidationError } from '@angular/forms/signals';
interface FieldErrorState {
touched: Signal<boolean>;
invalid: Signal<boolean>;
errors: Signal<readonly ValidationError[]>;
}
export function firstError(state: FieldErrorState): string | null {
if (!state.touched() || !state.invalid()) {
return null;
}
return state.errors().find((e) => !!e.message)?.message ?? null;
}
In the template, pass the field state in and @if on the result:
<input id="email" type="email" [formField]="profileForm.email" />
@if (firstError(profileForm.email()); as error) {
<div class="invalid-feedback">{{ error }}</div>
}
Submitting
On submit, check whole-form validity and mark everything touched so errors appear:
save() {
if (this.profileForm().invalid()) {
this.profileForm().markAsTouched();
return;
}
// model() holds your clean, typed data — send it straight to the API.
this.api.saveProfile(this.model());
}
Notice there’s no mapping step. this.model() is already the exact shape your API wants.
A side-by-side
The same simple form, before and after:
// BEFORE — reactive forms
form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
});
submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const value = this.form.getRawValue(); // map back to your model
this.api.save(value);
}
// AFTER — signal forms
model = signal({ name: '', email: '' });
form = form(this.model, (f) => {
required(f.name);
required(f.email);
email(f.email);
});
submit() {
if (this.form().invalid()) {
this.form().markAsTouched();
return;
}
this.api.save(this.model()); // already the right shape
}
Less code, fully typed, and the data never leaves the model.
How to upgrade an existing app
You don’t rewrite everything at once. Signal forms and reactive forms coexist fine, so migrate by form:
- Pick a small, self-contained form first — a modal or a settings panel is ideal. Get the patterns (the
firstErrorhelper, your validation-message markup) nailed down on something low-risk. - Define the model interface from the data you actually submit, and seed a
signal()with sensible defaults. - Translate validators into the schema.
Validators.required→required(f.x),Validators.maxLength(n)→maxLength(f.x, n), and any conditional logic fromvalueChangesbecomes awhen. - Swap the template bindings —
formControlName="x"becomes[formField]="form.x", and replace your error component with thefirstError@ifblock. - Delete the
ReactiveFormsModuleimport from that component once it’s converted, and dropFormBuilderfrom the constructor. - Repeat per form. There’s no flag day; each converted form is an independent win.
A couple of things to watch:
- Custom/third-party controls need to understand
[formField]. Components built onControlValueAccessorgenerally work, but verify your component library (date pickers, select widgets) binds correctly before converting a form that depends on them. - For Standard Schema fans,
validateStandardSchema()lets you drive validation from a Zod or Valibot schema, andvalidateHttp()covers async server-side checks like “is this username taken.”
Conclusion
Signal forms collapse the two-world problem of reactive forms into a single typed signal, move validation next to the fields it protects, and slot naturally into the rest of Angular’s signal-based, zoneless future. Now that they’re stable in Angular 22, there’s no reason to start a new form any other way — and upgrading old ones is a pleasant, incremental, one-form-at-a-time job. Start with a modal, feel how much smaller it gets, and work outward from there.