Skip to content

Signal Forms in Angular 21-22

Signal Forms represent a fundamental rethink of reactive forms in Angular. Introduced in Angular 21 and stabilized in Angular 22, they provide a simpler, more type-safe alternative to Reactive Forms by leveraging Angular signals.

Instead of managing form state through FormControl, FormGroup, and FormArray with manual subscriptions, Signal Forms keep the model as the single source of truth and synchronize automatically through signals.

Key advantages:

  • Full type safety without manual casting
  • Automatic reactivity with computed signals
  • Simplified custom control creation
  • Natural data binding without ControlValueAccessor ceremony
  • Gradual migration path from Reactive Forms

Start with a plain writable signal describing your form shape:

import { signal } from '@angular/core';
export class LoginComponent {
readonly loginModel = signal({
email: '',
password: ''
});
}

Wrap the model with form() and declare validation rules in the schema callback:

import { Component, signal } from '@angular/core';
import { form, required, email, minLength } from '@angular/forms/signals';
@Component({
selector: 'app-login',
template: `...`
})
export class LoginComponent {
readonly loginModel = signal({
email: '',
password: ''
});
readonly loginForm = form(this.loginModel, (schema) => {
required(schema.email, { message: 'Email is required' });
email(schema.email, { message: 'Enter a valid email' });
required(schema.password, { message: 'Password is required' });
minLength(schema.password, 8, { message: 'At least 8 characters' });
});
}

Use the [formField] directive to bind controls:

<form [formRoot]="loginForm">
<div>
<label for="email">Email</label>
<input id="email" [formField]="loginForm.email" />
@if (loginForm.email().invalid() && loginForm.email().touched()) {
<div class="error">
@for (error of loginForm.email().errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
<div>
<label for="password">Password</label>
<input id="password" type="password" [formField]="loginForm.password" />
@if (loginForm.password().invalid() && loginForm.password().touched()) {
<div class="error">
@for (error of loginForm.password().errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
<button [disabled]="loginForm().invalid()">Log in</button>
</form>

Remember to import FormField and FormRoot:

import { FormField, FormRoot } from '@angular/forms/signals';
@Component({
imports: [FormField, FormRoot]
})
export class LoginComponent { }

In Reactive Forms, every control defaults to T | null:

// Reactive Forms
const emailControl = new FormControl('');
// Type: FormControl<string | null> — always nullable!
// Signal Forms — type from model
const model = signal({ email: '' });
const myForm = form(model);
myForm.email().value(); // string — no null!

The get() method breaks type inference:

// Reactive Forms — loses type
const form = new FormGroup({
user: new FormGroup({
email: new FormControl('')
})
});
const email = form.get('user.email');
// Type: AbstractControl<unknown, unknown> | null
// Signal Forms — full typing
const model = signal({
user: { email: '' }
});
const myForm = form(model);
myForm.user.email().value(); // string — full path typed!
myForm.user.emial; // ❌ Compilation error!

Arrays lose their element type information:

// Reactive Forms
users.at(0); // AbstractControl — type lost!
// Signal Forms — structure preserved
interface User {
name: string;
email: string;
}
const model = signal<{ users: User[] }>({
users: [{ name: 'Jan', email: 'jan@example.com' }]
});
const myForm = form(model);
myForm.users[0].name().value(); // string — fully typed!

Adding fields at runtime breaks the type:

// Reactive Forms — TypeScript unaware of added field
const form = new FormGroup({ name: new FormControl('') });
form.addControl('email', new FormControl(''));
form.controls.email; // ❌ Property 'email' does not exist
// Signal Forms — model is source of truth
const model = signal<{ name: string; email?: string }>({
name: ''
});
const myForm = form(model);
model.update(m => ({ ...m, email: 'user@example.com' }));
if (myForm.email) {
myForm.email().value(); // string — now typed!
}

Signal Forms provide typed validators with integrated messages:

import {
form,
required,
email,
minLength,
maxLength,
pattern,
min,
max,
minDate,
maxDate
} from '@angular/forms/signals';
const checkoutForm = form(this.model, (schema) => {
required(schema.firstName);
minLength(schema.firstName, 2, { message: 'Minimum 2 characters' });
required(schema.email);
email(schema.email);
required(schema.cardNumber);
pattern(schema.cardNumber, /^\d{16}$/, {
message: 'Card number must be 16 digits'
});
required(schema.expiryDate);
minDate(schema.expiryDate, new Date(), {
message: 'Card must not be expired'
});
});

Create custom validation rules that react to other fields:

import { form, validate } from '@angular/forms/signals';
const signupForm = form(this.model, (schema) => {
required(schema.username);
validate(schema.username, ({ value }) => {
const username = value();
if (username.includes(' ')) {
return { kind: 'no-spaces', message: 'Username cannot contain spaces' };
}
return undefined;
});
required(schema.password);
minLength(schema.password, 8);
required(schema.confirmPassword);
validate(schema.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schema.password)) {
return { kind: 'password-mismatch', message: 'Passwords do not match' };
}
return undefined;
});
});

Apply rules based on other field values:

import { form, required, disabled, hidden } from '@angular/forms/signals';
const orderForm = form(this.model, (schema) => {
required(schema.customerType);
hidden(schema.companyName, {
when: ({ valueOf }) => valueOf(schema.customerType) !== 'business'
});
required(schema.companyName, {
when: ({ valueOf }) => valueOf(schema.customerType) === 'business'
});
disabled(schema.discountCode, {
when: ({ valueOf }) => valueOf(schema.orderType) === 'wholesale',
message: 'Discount codes do not apply to wholesale orders'
});
});

Server-side validation with automatic debouncing:

import { form, required, validateHttp } from '@angular/forms/signals';
const registrationForm = form(this.model, (schema) => {
required(schema.username);
validateHttp(schema.username, {
debounce: 300,
request: ({ value }) =>
value() ? `/api/check-username?name=${value()}` : undefined,
onSuccess: (result: { taken: boolean }) =>
result.taken
? { kind: 'taken', message: 'Username already registered' }
: undefined,
onError: () => ({
kind: 'server-error',
message: 'Could not verify username'
})
});
});

Define validation rules once and apply them everywhere:

import { schema, required, email, minLength } from '@angular/forms/signals';
export const addressSchema = schema<Address>((addr) => {
required(addr.street);
required(addr.city);
required(addr.zipCode);
pattern(addr.zipCode, /^\d{2}-\d{3}$/);
});
export const contactSchema = schema<Contact>((contact) => {
required(contact.email);
email(contact.email);
minLength(contact.phone, 9);
});

Apply schemas to nested objects and arrays:

import { form, apply, applyEach } from '@angular/forms/signals';
const customerForm = form(this.model, (schema) => {
required(schema.name);
apply(schema.billingAddress, addressSchema);
apply(schema.shippingAddress, addressSchema);
apply(schema.contact, contactSchema);
});
const orderForm = form(this.model, (schema) => {
applyEach(schema.addresses, addressSchema);
});

Conditional schema application:

import { applyWhen, applyWhenValue } from '@angular/forms/signals';
const form = form(this.model, (schema) => {
applyWhen(
schema.payment,
({ valueOf }) => valueOf(schema.paymentMethod) === 'card',
cardPaymentSchema
);
});

Configure submission in the form definition:

import { form, required, FormField, FormRoot, submit } from '@angular/forms/signals';
import { Component, signal, inject } from '@angular/core';
@Component({
selector: 'app-checkout',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="checkoutForm">
<input [formField]="checkoutForm.email" />
<button [disabled]="checkoutForm().submitting()">
{{ checkoutForm().submitting() ? 'Processing...' : 'Pay Now' }}
</button>
</form>
`
})
export class CheckoutComponent {
private readonly paymentService = inject(PaymentService);
readonly checkoutModel = signal({ email: '', amount: 100 });
readonly checkoutForm = form(
this.checkoutModel,
(schema) => {
required(schema.email);
},
{
submission: {
action: async (field) => {
const result = await this.paymentService.charge(field().value());
if (result.error) {
return {
kind: 'server',
message: result.error,
fieldTree: field.email
};
}
return undefined;
},
ignoreValidators: 'none', // Wait for all async validators
onInvalid: (field) => {
field().errorSummary()[0]?.fieldTree().focusBoundControl();
}
}
}
);
}

Handle save vs. submit scenarios:

import { submit } from '@angular/forms/signals';
async requestApproval(): Promise<void> {
const ok = await submit(this.orderForm, {
action: async (field) => {
return this.store.requestApproval(field().value());
},
ignoreValidators: 'none'
});
if (ok) {
// Handle success
}
}

Create a custom control without ControlValueAccessor boilerplate:

import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-custom-input',
template: `
<input
[value]="value()"
(input)="value.set($event.target.value)"
[disabled]="disabled()"
/>
`
})
export class CustomInputComponent implements FormValueControl<string> {
// Required: current value
readonly value = model('');
// Optional: FormField will bind these automatically
readonly disabled = input(false);
readonly touched = input(false);
readonly errors = input<readonly ValidationError[]>([]);
readonly invalid = input(false);
}

For boolean controls:

import { Component, model } from '@angular/core';
import { FormCheckboxControl } from '@angular/forms/signals';
@Component({
selector: 'app-custom-checkbox',
template: `
<label>
<input
type="checkbox"
[checked]="checked()"
(change)="checked.set($event.target.checked)"
[disabled]="disabled()"
/>
<ng-content></ng-content>
</label>
`
})
export class CustomCheckboxComponent implements FormCheckboxControl {
readonly checked = model(false);
readonly disabled = input(false);
readonly touched = input(false);
readonly errors = input<readonly ValidationError[]>([]);
}

Nesting is automatic through model shape:

const checkoutModel = signal({
customer: {
firstName: '',
lastName: '',
email: ''
},
shipping: {
street: '',
city: '',
zipCode: ''
}
});
const checkoutForm = form(checkoutModel, (schema) => {
required(schema.customer.firstName);
required(schema.customer.email);
email(schema.customer.email);
required(schema.shipping.street);
required(schema.shipping.city);
});

Bind nested fields naturally:

<input [formField]="checkoutForm.customer.firstName" />
<input [formField]="checkoutForm.customer.email" />
<input [formField]="checkoutForm.shipping.street" />

Use applyEach for repeating sections:

const orderModel = signal({
items: [
{ productId: '', quantity: 1 }
]
});
const orderForm = form(orderModel, (schema) => {
applyEach(schema.items, (item) => {
required(item.productId);
min(item.quantity, 1);
});
});

Render with @for:

@for (item of orderForm.items; track $index) {
<div class="row">
<input [formField]="item.productId" />
<input type="number" [formField]="item.quantity" />
</div>
}
<button (click)="addItem()">Add Item</button>

Add/remove items by updating the signal:

addItem(): void {
this.orderModel.update(model => ({
...model,
items: [...model.items, { productId: '', quantity: 1 }]
}));
}
removeItem(index: number): void {
this.orderModel.update(model => ({
...model,
items: model.items.filter((_, i) => i !== index)
}));
}

Wrap existing Reactive Forms into Signal Forms:

import { compatForm } from '@angular/forms/signals/compat';
import { FormControl, Validators } from '@angular/forms';
export class MixedFormComponent {
private readonly ageControl = new FormControl(5, Validators.min(3));
readonly model = signal({
name: 'John',
age: this.ageControl
});
readonly myForm = compatForm(this.model);
}

Both systems stay in sync bidirectionally. Useful when you want to gradually modernize parts of a form.

Use signal-based controls within existing FormGroups:

import { SignalFormControl } from '@angular/forms/signals/compat';
import { FormBuilder } from '@angular/forms';
import { required, email, validateHttp } from '@angular/forms/signals';
@Component({
selector: 'app-user-form'
})
export class UserFormComponent {
private readonly fb = inject(FormBuilder);
readonly emailControl = new SignalFormControl('', (path) => {
required(path);
email(path);
validateHttp(path, {
debounce: 300,
request: ({ value }) => `/api/check-email?email=${value()}`,
onSuccess: (res: { taken: boolean }) =>
res.taken ? { kind: 'taken', message: 'Already registered' } : null
});
});
readonly userForm = this.fb.group({
firstName: ['', Validators.required],
email: this.emailControl
});
}

FeatureReactive FormsSignal Forms
Form stateManual FormControl/GroupSignal model
Type safetyT | null defaultType from model
Navigationget() loses typesDot notation preserves type
ArraysFormArray abstractionNative array in signal
Custom controlsControlValueAccessorFormValueControl (one method!)
ValidationValidator arraySchema callback (centralized)
ReactivityManual subscriptionsAutomatic with signals
Nested fieldsNested form structuresModel structure reflected

Switch between display and edit mode:

const isEditing = signal(false);
const contactModel = signal({ name: 'John', email: 'john@example.com' });
readonly contactForm = form(contactModel, (schema) => {
required(schema.name);
email(schema.email);
});
saveContact(): void {
if (this.contactForm().valid()) {
// Persist changes
isEditing.set(false);
}
}
cancelEdit(): void {
this.contactForm().reset();
isEditing.set(false);
}

Multi-step form with per-step validation:

const currentStep = signal(1);
readonly wizardModel = signal({
step1: { name: '', email: '' },
step2: { company: '', role: '' },
step3: { phone: '', newsletter: false }
});
readonly wizardForm = form(this.wizardModel, (schema) => {
applyWhen(schema.step1, () => currentStep() >= 1, step1Schema);
applyWhen(schema.step2, () => currentStep() >= 2, step2Schema);
applyWhen(schema.step3, () => currentStep() >= 3, step3Schema);
});
nextStep(): void {
if (this.currentStepIsValid()) {
currentStep.update(s => s + 1);
}
}

Show/hide entire form sections:

const orderType = signal<'standard' | 'wholesale'>('standard');
readonly orderForm = form(this.model, (schema) => {
required(schema.items);
applyWhen(
schema.discount,
({ valueOf }) => valueOf(schema.orderType) === 'standard'
);
applyWhen(
schema.bulkDiscount,
({ valueOf }) => valueOf(schema.orderType) === 'wholesale'
);
});

Signal Forms have separate entry points:

// Core Signal Forms API
import { form, required, validate, FormField, FormRoot } from '@angular/forms/signals';
// Interop with Reactive Forms
import { compatForm, SignalFormControl } from '@angular/forms/signals/compat';
// CSS class configuration
import { provideSignalFormsConfig, NG_STATUS_CLASSES } from '@angular/forms/signals';

Configure which state classes Angular applies:

import { provideSignalFormsConfig, NG_STATUS_CLASSES } from '@angular/forms/signals';
export const appConfig: ApplicationConfig = {
providers: [
provideSignalFormsConfig({
classes: NG_STATUS_CLASSES // Restore classic ng-valid, ng-invalid, etc.
})
]
};

Or customize:

provideSignalFormsConfig({
classes: {
'ng-valid': ({ state }) => state().valid(),
'ng-invalid': ({ state }) => state().invalid(),
'ng-touched': ({ state }) => state().touched(),
'ng-dirty': ({ state }) => state().dirty(),
'is-warning': ({ state }) => state().dirty() && state().valid()
}
})

  • Stable since: Angular 22.0
  • Preview in: Angular 21
  • Minimum version: Angular 21

Signal Forms are production-ready and recommended for new applications. Reactive Forms remain fully supported.


  1. Stefańczyk, Mateusz. “Signal Forms – Complete Guide v.21-22.” Angular.love, 2024. ↩

  2. Ayaz, Muhammad Ahsan. “Angular Signal Forms Tutorial (v22).” Medium, June 29, 2026. ↩

  3. Martorana, Marco. “Angular 22: Embracing the Signal-First Era.” Medium, May 19, 2026. ↩

  4. Angular Documentation. “Signals Guide.” Angular.io. ↩