Angular Signal Forms: Build Reactive Forms Without the Boilerplate
Angular Signal Forms are now stable in Angular 22. Learn how to build reactive forms with signals, schema validation, and zero subscriptions in this practical guide.
TL;DR: Angular Signal Forms — now stable in Angular 22 — replace
FormGroup/FormControlwith a signal-backedform()function, a schema for validation, and a single[formField]directive on inputs. You get reactive field state (value, errors, dirty, touched) as signals you can read anywhere in the component, with no subscriptions, novalueChanges, and no manual change detection calls.
Angular Signal Forms arrived as an experimental API in Angular 21 and reached stable status with Angular 22 in June 2026. If you have been waiting before adopting them in production, that moment is now. This guide walks through the complete API — from a minimal login form to a multi-field registration form with cross-field validation — and shows where Signal Forms eliminate the friction that made ReactiveFormsModule frustrating.
The Problem: Why ReactiveFormsModule Gets Painful
ReactiveFormsModule introduced a big improvement over template-driven forms: you described your form in TypeScript, not in the template, which made validation testable. But the implementation has aged. In a modern, signals-based Angular app, the old API generates three categories of friction.
First, imperative state reads. To check whether a field is invalid, you call this.form.get('email')?.invalid. That is a property access on a non-null-asserted chain, not a reactive read — it does not trigger OnPush updates automatically.
Second, subscriptions everywhere. Reacting to value changes means subscribing to FormControl.valueChanges and cleaning up in ngOnDestroy. Every cross-field interaction (show a hint when the user types more than five characters) requires more Observable plumbing.
Third, type safety has limits. FormGroup<{email: FormControl<string>}> became the strongly-typed API in Angular 14, but the generic signatures get verbose quickly and the types do not flow through .get() calls cleanly.
Signal Forms solve all three. Form state is a tree of signals — you read it with (), derive from it with computed(), and Angular handles the rest.
The Signal Forms API at a Glance
Signal Forms live in @angular/forms/signals, a new sub-path export shipped alongside the existing @angular/forms. The key exports are:
form(data, schema)— binds a writable signal to a schema and returns a FieldTreeschema<T>(path => ...)— defines validation rules as a function over typed field pathsrequired,minLength,maxLength,pattern,email,min,max— built-in validator functions used insideschema()FormField— the standalone directive imported into your component that you attach to any<input>or<textarea>via[formField]
Each field on the FieldTree is itself a signal. Calling it returns a FieldState object with:
.value()— the current field value.invalid()— boolean, true when any validator fails.errors()— object of active validation errors, ornull.dirty()— true after the user has changed the field.touched()— true after the field has lost focus
Every one of these is a signal. You can read them in computed(), in effect(), or directly in the template — and OnPush components update correctly because Angular tracks these reads.
You can read the full Signal Forms specification on angular.dev for the complete API reference.
Building a Login Form
Start with the simplest real-world case: an email and password login form.
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { form, FormField, schema, required, email, minLength } from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
}
const loginSchema = schema<LoginData>(path => {
required(path.email);
email(path.email);
required(path.password);
minLength(path.password, 8);
});
@Component({
selector: 'app-login-form',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormField],
template: `
<form (ngSubmit)="submit()">
<label>
Email
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<span class="field-error">
@if (loginForm.email().errors()?.['required']) { Email is required. }
@if (loginForm.email().errors()?.['email']) { Enter a valid email address. }
</span>
}
</label>
<label>
Password
<input type="password" [formField]="loginForm.password" />
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<span class="field-error">
@if (loginForm.password().errors()?.['required']) { Password is required. }
@if (loginForm.password().errors()?.['minlength']) {
Password must be at least 8 characters.
}
</span>
}
</label>
<button type="submit" [disabled]="formInvalid()">Sign in</button>
</form>
`,
})
export class LoginFormComponent {
protected readonly loginData = signal<LoginData>({ email: '', password: '' });
protected readonly loginForm = form(this.loginData, loginSchema);
protected readonly formInvalid = computed(() =>
this.loginForm.email().invalid() || this.loginForm.password().invalid()
);
submit(): void {
if (this.formInvalid()) return;
// loginData() is already fully typed as LoginData
console.log(this.loginData());
}
}
A few things to notice. The loginData signal is the source of truth for your form's values. The form() call returns a FieldTree whose shape mirrors the LoginData interface — TypeScript knows that loginForm.email exists and loginForm.nonexistent does not. The formInvalid computed signal is a perfectly ordinary Angular signal that works with OnPush and needs no special form APIs.
To read the current form value for submission, you just call this.loginData() — you already have it.
Cross-Field Validation with computed()
One of the sharpest improvements over ReactiveFormsModule is cross-field validation. In the old API, cross-field validators were attached to the FormGroup level, which meant the error appeared on the group, not the field — you had to manually surface it in the template.
With Signal Forms, you derive cross-field errors in a plain computed():
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { form, FormField, schema, required, minLength } from '@angular/forms/signals';
interface RegisterData {
username: string;
password: string;
confirmPassword: string;
}
const registerSchema = schema<RegisterData>(path => {
required(path.username);
minLength(path.username, 3);
required(path.password);
minLength(path.password, 10);
required(path.confirmPassword);
});
@Component({
selector: 'app-register-form',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormField],
template: `
<form (ngSubmit)="submit()">
<label>
Username
<input [formField]="registerForm.username" />
@if (registerForm.username().touched() && registerForm.username().invalid()) {
<span class="field-error">Username must be at least 3 characters.</span>
}
</label>
<label>
Password
<input type="password" [formField]="registerForm.password" />
</label>
<label>
Confirm password
<input type="password" [formField]="registerForm.confirmPassword" />
@if (passwordMismatch()) {
<span class="field-error">Passwords do not match.</span>
}
</label>
<button type="submit" [disabled]="cannotSubmit()">Create account</button>
</form>
`,
})
export class RegisterFormComponent {
protected readonly registerData = signal<RegisterData>({
username: '',
password: '',
confirmPassword: '',
});
protected readonly registerForm = form(this.registerData, registerSchema);
protected readonly passwordMismatch = computed(() => {
const data = this.registerData();
return (
this.registerForm.confirmPassword().touched() &&
data.password !== data.confirmPassword
);
});
protected readonly cannotSubmit = computed(
() =>
this.registerForm.username().invalid() ||
this.registerForm.password().invalid() ||
this.passwordMismatch()
);
submit(): void {
if (this.cannotSubmit()) return;
const { confirmPassword, ...payload } = this.registerData();
console.log('register:', payload);
}
}
passwordMismatch is not a special validation primitive — it is an ordinary computed(). It reads from registerData() (the raw signal) and from the FieldTree's .touched() state. Angular tracks both dependencies, so the view updates whenever either changes. This is the pattern to internalize: validation logic you control belongs in computed(), not in a custom validator class.
Custom Async Validators
Signal Forms also support async validation — for example, checking whether a username is already taken. You pass an async validator as the second argument to any built-in like required(), or use the standalone asyncValidator() function:
import { asyncValidator, form, schema, required } from '@angular/forms/signals';
import { inject } from '@angular/core';
import { UsersService } from './users.service';
// Inside your component class
private readonly usersService = inject(UsersService);
protected readonly accountSchema = schema<{ username: string }>(path => {
required(path.username);
asyncValidator(path.username, async (value) => {
if (value.length < 3) return null; // skip the call for short inputs
const taken = await this.usersService.checkUsername(value);
return taken ? { usernameTaken: true } : null;
});
});
The FieldTree reflects async validation state through an extra .pending() signal on the field, so you can show a spinner without any extra state:
<input [formField]="accountForm.username" />
@if (accountForm.username().pending()) {
<span>Checking availability...</span>
}
@if (accountForm.username().errors()?.['usernameTaken']) {
<span class="field-error">That username is taken.</span>
}
Using ZyraUI for Angular Signal Forms
Building forms by hand means writing your own input styling, error message components, and focus ring behavior for every project. ZyraUI ships pre-built, accessible form components — inputs, selects, textareas, checkboxes — that drop directly into your Signal Forms template. They are standalone Angular components that work with [formField] or through native value/(input) bindings, and they respect all five ZyraUI themes out of the box.
Here is the registration form from above rewritten with ZyraUI's zyra-input and zyra-button:
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { form, FormField, schema, required, minLength } from '@angular/forms/signals';
import { ZyraInputComponent, ZyraButtonComponent } from '@zyra-ui/angular';
interface RegisterData {
username: string;
password: string;
confirmPassword: string;
}
const registerSchema = schema<RegisterData>(path => {
required(path.username);
minLength(path.username, 3);
required(path.password);
minLength(path.password, 10);
required(path.confirmPassword);
});
@Component({
selector: 'app-register-form',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormField, ZyraInputComponent, ZyraButtonComponent],
template: `
<form (ngSubmit)="submit()">
<zyra-input
label="Username"
[formField]="registerForm.username"
[error]="registerForm.username().touched() && registerForm.username().invalid()
? 'Username must be at least 3 characters.'
: null"
/>
<zyra-input
label="Password"
type="password"
[formField]="registerForm.password"
/>
<zyra-input
label="Confirm password"
type="password"
[formField]="registerForm.confirmPassword"
[error]="passwordMismatch() ? 'Passwords do not match.' : null"
/>
<zyra-button type="submit" [disabled]="cannotSubmit()">
Create account
</zyra-button>
</form>
`,
})
export class RegisterFormComponent {
protected readonly registerData = signal<RegisterData>({
username: '',
password: '',
confirmPassword: '',
});
protected readonly registerForm = form(this.registerData, registerSchema);
protected readonly passwordMismatch = computed(() => {
const data = this.registerData();
return (
this.registerForm.confirmPassword().touched() &&
data.password !== data.confirmPassword
);
});
protected readonly cannotSubmit = computed(
() =>
this.registerForm.username().invalid() ||
this.registerForm.password().invalid() ||
this.passwordMismatch()
);
submit(): void {
if (this.cannotSubmit()) return;
const { confirmPassword, ...payload } = this.registerData();
console.log('register:', payload);
}
}
zyra-input accepts a label, type, [formField], and an [error] binding that renders the error message with the correct color, spacing, and ARIA attributes automatically. Your component class stays focused on validation logic rather than error-display plumbing. You can explore all 60+ free components at zyraui.dev, including inputs, selects, buttons, and more.
Migrating from ReactiveFormsModule
If you have an existing ReactiveFormsModule form to migrate, the conceptual mapping is straightforward:
new FormGroup({ email: new FormControl('') })→signal<MyData>({ email: '' })+form(data, schema)Validators.requiredinsideFormControl→required(path.field)insideschema()formGroup.get('email')?.value→loginData().email(read the raw signal) orloginForm.email().value()formGroup.get('email')?.invalid→loginForm.email().invalid()formControl.valueChanges.pipe(...)→computed()oreffect()onloginDataformGroup.patchValue({ email: 'x' })→loginData.update(d => ({ ...d, email: 'x' }))
The migration can be done field by field. Nothing prevents you from having a ReactiveFormsModule form in one component and a Signal Forms form in the next while you migrate incrementally.
The Angular team's migration guide on angular.dev walks through automated schematics that handle the most common patterns, if you prefer a script-assisted approach. You can also browse the ZyraUI docs for Angular-specific patterns that complement Signal Forms in real projects.
Wrapping Up
Angular Signal Forms remove the three biggest pain points of ReactiveFormsModule: imperative state reads that break OnPush, subscriptions for reactive behavior, and awkward cross-field validation APIs. The result is a form API that fits naturally into a signals-first Angular codebase — validation logic is a computed(), submission state is a computed(), and the template reads signals with () just like everything else.
Pair Signal Forms with ZyraUI's form components to avoid writing error-display and focus-ring boilerplate for every project. The combination lets you ship polished, accessible forms in significantly less code.
Frequently asked questions
What is Angular Signal Forms and how does it differ from ReactiveFormsModule?
Angular Signal Forms is a new form API in @angular/forms/signals that manages form state as a writable signal rather than FormGroup/FormControl objects. Field validity, errors, dirty state, and touched state are all signals you read with (), which means they work natively with OnPush change detection and can be composed with computed() and effect() — no subscriptions required.
Are Angular Signal Forms stable and safe to use in production?
Yes. Signal Forms graduated from experimental to stable in Angular 22, released June 3, 2026. The API is now covered by Angular's semantic versioning policy, so breaking changes require a major version bump with a deprecation period.
How do I handle cross-field validation with Angular Signal Forms?
Cross-field validation in Signal Forms is just a computed() that reads from the raw data signal and from the individual field state signals — for example, checking that password equals confirmPassword. You surface the error in the template by reading that computed signal with @if. No custom validator classes or group-level validators are needed.
Can I use Angular Signal Forms with existing UI component libraries?
Yes. The [formField] directive works with any standard <input> or <textarea> element. For UI component libraries like ZyraUI, you can bind the FieldTree signal's value and error state directly to the component's [error] input. Most Angular UI libraries expose plain @Input bindings that accept the signal-derived values cleanly.