β Angular 22 promoted Signal Forms from experimental to stable. This is not "Reactive Forms are dead." It's a real architectural trade-off, and this post walks through both APIs in full, with production-realistic code, so you can decide feature-by-feature instead of framework-war-by-framework-war.
Table of Contents
- Why This Matters Now
- The Core Question
- Reactive Forms: Why It Became the Standard
- Signal Forms: What Actually Changed in Angular 22
- Side-by-Side: Core Concepts Mapped
- Deep Dive: Validation
- Deep Dive: Dynamic and Nested Forms
- Deep Dive: Form State β Dirty, Touched, Errors, Submission
- Developer Experience and Testing
- Performance Considerations
- Interop: Migrating Without a Big-Bang Rewrite
- Migration Strategy for Enterprise Teams
- When NOT to Migrate
- Decision Framework
- FAQ
- Closing Thoughts
Why This Matters Now
With Angular 22 (released June 3, 2026), Signal Forms left experimental status and became part of the stable, supported API β alongside resource() and httpResource(). That's a meaningful milestone: it means the Angular team ran extensive internal case studies across real form-heavy applications at Google before committing to stability, and the interop story with Reactive Forms has matured enough that a big-bang rewrite is no longer the only migration path.
At the same time, Angular 22 also flips two important defaults: components now use OnPush change detection by default, and zoneless change detection continues its push toward becoming the standard. Signal Forms is part of that same story β Angular's reactivity model finally speaking one dialect end-to-end, from component state to form state to async data.
None of this makes Reactive Forms obsolete. It changes what "the default choice for a new form" should be, and it's worth understanding precisely why.
The Core Question
The question isn't "Which API is better?"
It's "Which API best fits this feature?"
That distinction matters more in enterprise Angular than almost anywhere else, because forms are rarely isolated. They're wired into validation pipelines, state stores, third-party ControlValueAccessor components, QA test suites, and design systems that took years to harden. A framework upgrade is not a reason to touch any of that on its own.
Reactive Forms: Why It Became the Standard
Reactive Forms gave Angular teams something rare in frontend tooling: a predictable, testable, and battle-tested way to model form state, built on FormGroup, FormControl, and RxJS.
Typed Forms (introduced a few years back) closed the biggest historical gap β loose any-typed values β and the Observable foundation made async validation, cross-field logic, and dynamic FormArray structures possible, even if verbose.
Full Example: Reactive Forms Login
// login.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.component.html',
})
export class LoginComponent {
private fb = inject(FormBuilder);
loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
get email() {
return this.loginForm.controls.email;
}
get password() {
return this.loginForm.controls.password;
}
submit(): void {
if (this.loginForm.invalid) {
this.loginForm.markAllAsTouched();
return;
}
console.log(this.loginForm.getRawValue());
}
}
<!-- login.component.html -->
<form [formGroup]="loginForm" (ngSubmit)="submit()">
<label>
Email
<input type="email" formControlName="email" />
</label>
@if (email.invalid && email.touched) {
<p class="error">Enter a valid email address.</p>
}
<label>
Password
<input type="password" formControlName="password" />
</label>
@if (password.invalid && password.touched) {
<p class="error">Password must be at least 8 characters.</p>
}
<button type="submit" [disabled]="loginForm.invalid">Log in</button>
</form>
This is idiomatic, production-grade Reactive Forms code. Nothing about it is wrong. It's verbose in predictable, well-understood ways β and that predictability is exactly why it scaled to enterprise codebases for a decade.
Where Reactive Forms Still Excel
-
Large legacy applications with years of accumulated form logic and test coverage built around
FormGroup/FormControl. -
Deeply customized dynamic forms wired to many third-party
ControlValueAccessorcomponents that haven't been touched in the migration yet. - Teams with heavy tooling investment β custom validators, form-testing harnesses, and internal libraries built specifically around the Reactive Forms API.
- Stability-first contexts β a decade of production hardening and ecosystem depth is not something a new API can replicate on day one, no matter how solid.
Signal Forms: What Actually Changed in Angular 22
Signal Forms build directly on Angular's signal primitives. Instead of building a FormGroup tree of AbstractControls backed by RxJS, you call form() on a signal holding your data model, and you get back a fully typed FieldTree β a deeply nested reactive structure where every field's value, validity, dirty/touched state, and errors are themselves signals.
computed() and effect() slot naturally into validation and derived UI state, instead of living in a parallel RxJS world that you have to manually bridge with toSignal()/toObservable().
Full Example: Signal Forms Login
// login.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, required, email, minLength } from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
@Component({
selector: 'app-login',
standalone: true,
imports: [FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './login.component.html',
})
export class LoginComponent {
protected readonly model = signal<LoginData>({ email: '', password: '' });
protected readonly loginForm = form(this.model, (path) => {
required(path.email);
email(path.email);
required(path.password);
minLength(path.password, 8);
});
submit(): void {
if (this.loginForm().invalid()) return;
console.log(this.model());
}
}
<!-- login.component.html -->
<form (submit)="submit()">
<label>
Email
<input type="email" [formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0]?.message }}</p>
}
<label>
Password
<input type="password" [formField]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<p class="error">{{ loginForm.password().errors()[0]?.message }}</p>
}
<button type="submit" [disabled]="loginForm().invalid()">Log in</button>
</form>
Notice what's gone: no FormBuilder injection, no .controls.email getters, no manual markAllAsTouched() call scattered through submit logic (submission state is part of the field tree itself), and no RxJS subscription management anywhere in this component.
Where Signal Forms Shine
- New features and greenfield screens β anything you're writing from scratch in a signals-first, standalone-component codebase.
-
Forms with heavy cross-field dependencies β relationships that used to require manual subscriptions now read as plain
computed()logic. - Signals-first architectures β no bridging between RxJS-based form state and signal-based component state; it's one reactive model end-to-end.
- Teams adopting zoneless + OnPush β Signal Forms was designed alongside that broader reactivity shift, so the mental model is consistent across the whole component tree.
Side-by-Side: Core Concepts Mapped
| Concept | Reactive Forms | Signal Forms |
|---|---|---|
| Root construct | FormGroup |
form(modelSignal, schemaFn) |
| Field construct | FormControl |
FieldTree node (a signal) |
| Reading a value | control.value |
field().value() |
| Validity |
control.valid / .invalid
|
field().valid() / .invalid()
|
| Dirty / touched |
control.dirty / .touched
|
field().dirty() / .touched()
|
| Errors | control.errors |
field().errors() |
| Validation definition |
Validators.required etc. passed at construction |
required(path.x) inside schema function |
| Cross-field logic | Manual valueChanges subscriptions or custom validators |
computed() / validate() referencing other paths |
| Async validation |
AsyncValidatorFn returning an Observable |
Async validator functions integrated into the schema |
| Template binding |
formControlName / [formControl]
|
[formField] directive |
| Underlying model | RxJS Observables | Signals |
Deep Dive: Validation
Synchronous Validation
Reactive Forms:
age: ['', [Validators.required, Validators.min(18)]]
Signal Forms:
form(model, (path) => {
required(path.age);
min(path.age, 18);
});
Cross-Field Validation
This is one of the areas where Signal Forms genuinely simplifies things. In Reactive Forms, cross-field validation typically means a custom validator function attached at the group level:
function passwordsMatch(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { mismatch: true };
}
this.fb.group(
{
password: ['', Validators.required],
confirmPassword: ['', Validators.required],
},
{ validators: passwordsMatch }
);
In Signal Forms, the same relationship is expressed directly against the field paths, reading naturally like derived component state:
form(model, (path) => {
required(path.password);
required(path.confirmPassword);
validate(path.confirmPassword, ({ valueOf }) =>
valueOf(path.confirmPassword) === valueOf(path.password)
? null
: { kind: 'mismatch', message: 'Passwords must match' }
);
});
Conditional Validation with when()
Angular 22 standardizes conditional validation through a when() helper, replacing the ad hoc "check a sibling control's value inside a custom validator" pattern:
form(model, (path) => {
when(path.taxId, ({ valueOf }) => valueOf(path.country) === 'US', (p) => {
required(p);
});
});
Async Validation
Reactive Forms:
username: ['', [Validators.required], [this.usernameTakenValidator]]
usernameTakenValidator: AsyncValidatorFn = (control) =>
this.userService.checkUsername(control.value).pipe(
map((taken) => (taken ? { usernameTaken: true } : null))
);
Signal Forms:
form(model, (path) => {
required(path.username);
validateAsync(path.username, async ({ valueOf }) => {
const taken = await this.userService.checkUsername(valueOf(path.username));
return taken ? { kind: 'usernameTaken', message: 'Username is taken' } : null;
});
});
Both approaches support debouncing; Signal Forms can also lean on the new debounced() signal utility for search-style inputs that live outside a form schema entirely.
Deep Dive: Dynamic and Nested Forms
Nested Form Groups
Reactive Forms:
this.fb.group({
shipping: this.fb.group({
street: [''],
city: [''],
}),
});
Signal Forms:
interface Order {
shipping: { street: string; city: string };
}
const model = signal<Order>({ shipping: { street: '', city: '' } });
const orderForm = form(model, (path) => {
required(path.shipping.street);
required(path.shipping.city);
});
Nesting in Signal Forms is just nesting in your TypeScript interface β the FieldTree mirrors your model shape automatically, with full type safety at every level.
Dynamic Collections (FormArray-style)
Reactive Forms:
items: this.fb.array([this.createItemGroup()]);
createItemGroup() {
return this.fb.group({
sku: ['', Validators.required],
qty: [1, Validators.min(1)],
});
}
addItem(): void {
(this.form.get('items') as FormArray).push(this.createItemGroup());
}
Signal Forms:
interface OrderLine { sku: string; qty: number; }
interface Order { items: OrderLine[]; }
const model = signal<Order>({ items: [{ sku: '', qty: 1 }] });
const orderForm = form(model, (path) => {
required(path.items.sku);
min(path.items.qty, 1);
});
function addItem(): void {
model.update((order) => ({
...order,
items: [...order.items, { sku: '', qty: 1 }],
}));
}
@for (item of orderForm.items; track $index) {
<input [formField]="item.sku" />
<input type="number" [formField]="item.qty" />
}
<button type="button" (click)="addItem()">Add item</button>
Because the array lives on a plain signal, adding, removing, and reordering items is just signal-based array manipulation β no FormArray.push() / .removeAt() API to learn separately.
Deep Dive: Form State β Dirty, Touched, Errors, Submission
Reactive Forms exposes state as properties on each AbstractControl: dirty, touched, pristine, untouched, valid, invalid, pending, errors. You typically read these directly in templates or subscribe to statusChanges / valueChanges for programmatic logic.
Signal Forms exposes the same concepts, but every one of them is a signal on the FieldTree node β field().dirty(), field().touched(), field().errors(). This means:
- No manual subscription/teardown for reacting to state changes β just read the signal, or wrap it in
computed(). - Submission state (
submitting,submitted) is modeled explicitly through the Submission API rather than hand-rolled boolean flags on the component. - Error access is typed β
getError()on a field returns a specific, typed error shape rather than an untypedValidationErrorsobject.
protected readonly isFormBusy = computed(() => orderForm().submitting());
Developer Experience and Testing
Reactive Forms testing typically means constructing a FormBuilder group in a test, patching values, and asserting on .valid / .errors. It's mature and well-documented, with a decade of community patterns (@angular/core/testing, harnesses, etc.).
Signal Forms testing follows the same shape but reads more like testing component state: set the underlying model signal, then assert on the FieldTree's signals directly β no TestBed-specific form APIs to learn beyond what you already use for signal-based components.
it('marks confirmPassword invalid on mismatch', () => {
const model = signal({ password: 'abc12345', confirmPassword: 'xyz' });
const testForm = form(model, (path) => {
validate(path.confirmPassword, ({ valueOf }) =>
valueOf(path.confirmPassword) === valueOf(path.password) ? null : { kind: 'mismatch' }
);
});
expect(testForm.confirmPassword().invalid()).toBe(true);
});
One caveat worth naming honestly: Signal Forms is younger. The volume of community Q&A, edge-case write-ups, and third-party tooling around Reactive Forms is still far larger simply because it's been in production for years longer.
Performance Considerations
Reactive Forms' RxJS foundation is fully capable in production at scale, but large forms with many controls and manual valueChanges subscriptions can accumulate change-detection overhead if not carefully scoped, especially outside OnPush.
Signal Forms benefits from Angular's fine-grained reactivity model directly: because each field is its own signal, updating one field only triggers recomputation of the signals and templates that actually depend on it β which pairs naturally with OnPush (now the Angular 22 default) and zoneless change detection. For very large, deeply nested enterprise forms, this can reduce unnecessary re-renders compared to a coarser-grained FormGroup update cycle β though the difference is most noticeable at real scale (hundreds of fields), not in small forms.
Interop: Migrating Without a Big-Bang Rewrite
The most practically important change in Angular 22 for enterprise teams: existing ControlValueAccessor components β including third-party ones you don't control β now work with the [formField] directive.
<!-- A legacy CVA-based date picker, unchanged, used inside a Signal Form -->
<app-legacy-date-picker [formField]="orderForm.deliveryDate" />
This means migration doesn't require rewriting your custom form controls before you can adopt Signal Forms anywhere. You can lift a form's model into a signal, wire it both ways for a release if needed, and migrate the template to [formField] incrementally, field by field, rather than as one large risky cutover.
Migration Strategy for Enterprise Teams
A pragmatic order of operations for teams evaluating adoption:
-
New screens, new API. Any form written from scratch should default to
form(). Mixing both styles inside the same screen is a worse developer experience than committing to one. -
Target forms with real cross-field complexity first. These see the biggest maintainability payoff β relationships that were manual RxJS subscriptions become plain
computed()/validate()logic. -
Lift the model before touching the template. Introduce a
signal<FormShape>that mirrors your currentFormGroupvalue, keep both in sync for a release if the risk profile requires it, then swap the template to[formField]once the signal is the source of truth. - Leave large, heavily customized dynamic forms with many third-party CVAs for later. They benefit most from interop maturing further, and there's little urgency to touch them.
- Don't migrate the "boring CRUD pages" for their own sake. If a form isn't causing maintainability pain today, a stable label on a new API isn't a sufficient reason to touch it.
When NOT to Migrate
- The form works, has adequate test coverage, and isn't a source of ongoing maintenance pain.
- The form is deeply wired into third-party validation libraries or CVA components that haven't been evaluated for interop yet.
- Your team hasn't yet adopted signals as the default state model elsewhere in the codebase β introducing Signal Forms in isolation adds a second mental model rather than unifying one.
- The migration would consume time better spent on features or technical debt with clearer business impact.
Decision Framework
Ask, per feature, not per codebase:
- Is this a new form? β Default to Signal Forms.
- Does this form have significant cross-field validation logic today expressed as manual subscriptions or group-level custom validators? β Strong candidate for migration.
-
Does this form depend on many third-party CVA components you haven't tested with
[formField]yet? β Lower priority; revisit after interop is validated on smaller forms. - Is the form stable, tested, and not causing pain? β Leave it alone.
FAQ
Is Signal Forms replacing Reactive Forms?
No. There's no deprecation notice for @angular/forms. Both packages are supported, and the Angular team has been explicit that they'll coexist. @angular/forms/signals lives alongside the existing module.
Do I need to rewrite my custom validators?
Not necessarily all at once. Validation logic can be reframed as validate() / when() calls against a FieldTree path incrementally, and CVA-based custom controls now interoperate with [formField] directly.
Is Signal Forms only for standalone, zoneless apps?
No β it works in any Angular 22 app. It's simply designed with signals, standalone components, and OnPush in mind, so it feels most natural in codebases already leaning that direction.
What about Template-Driven Forms?
Template-Driven Forms remain a separate, simpler option for small forms and haven't changed in this release. This post focuses on the Reactive Forms vs. Signal Forms comparison since that's where the architectural decision is most relevant for enterprise teams.
Should a mid-size team migrate everything this quarter?
No. Start with one or two new features using Signal Forms, evaluate the developer experience and interop story against your own codebase's third-party dependencies, then expand the decision framework above to the rest of the team.
Closing Thoughts
Angular 22 turning Signal Forms stable is a real milestone, not just a version bump β but the right response to it is the same discipline senior teams apply to any framework shift: evaluate feature-by-feature, protect what's already stable and tested, and migrate where it demonstrably reduces complexity rather than because the API is new.
Choose the form architecture that minimizes complexity for the feature in front of you β not the newest API by default.
I share day-to-day insight on web development, best practices, performance, and modern Angular architecture. Follow along for more β Ouakala Abdelaaziz, Full Stack Developer, Programming Mastery Academy.
I write about Angular architecture, enterprise UI patterns, and frontend best practices at Programming Mastery Academy β follow along for more breakdowns like this one.
π More From Me
I share daily insights on web development, architecture, and frontend ecosystems.
Follow me here on Dev.to, and connect on LinkedIn for professional discussions.
π Connect With Me
If you enjoyed this post and want more insights on scalable frontend systems, follow my work across platforms:
π LinkedIn β Professional discussions, architecture breakdowns, and engineering insights.
πΈ Instagram β Visuals, carousels, and designβdriven posts under the Terminal Elite aesthetic.
π§ Website β Articles, tutorials, and project showcases.
π₯ YouTube β Deepβdive videos and live coding sessions.
Top comments (0)