Angular Signal Forms: The In-Depth 'Zero to Hero' Guide
Signal Forms are the future of forms in Angular. Move away from RxJS-based reactive forms and master signal-based forms: local state, validation, controls, form groups, custom validators, and real-world complex forms.
- 1. Angular Signals: The Complete Guide from Beginner to Advanced
- 2. Angular Without Zone.js: Signals, New Control Flow & Zoneless Change Detection
- 3. Angular Signal Forms: The In-Depth 'Zero to Hero' Guide
- 4. Angular Resource API: Master resource(), rxResource() & httpResource() from Scratch
Introduction
Let's be honest — forms in Angular have always been the part of the framework that developers tolerate rather than enjoy. Template-Driven Forms feel like magic you can't control. Reactive Forms gave you control... along with a mountain of RxJS subscriptions, type-casting gymnastics, and ngOnDestroy cleanup ceremonies.
Signal Forms change everything. Stable since Angular 22 (released June 3, 2026), Signal Forms ship in the @angular/forms/signals package and deliver type-safe, declarative form controls that expose all their state — value, validity, dirty, touched, pending — as reactive signals. Built on the same signal(), computed(), and effect() primitives you already know from Angular Signals, they require no RxJS subscriptions and almost no boilerplate.
In this zero-to-hero guide, we'll build up from a single input field to a complete user registration form with nested groups, dynamic arrays, custom sync validators, and async validation — all with Signal Forms.
The Limitations of Traditional Forms ⚠️
Before diving into Signal Forms, let's understand why we are moving away from traditional Reactive Forms:
- RxJS Overhead: Simple form updates require managing Observable subscriptions. Forgetting to unsubscribe or not handling operators correctly causes memory leaks.
- Boilerplate: Accessing single control values, errors, or statuses requires writing multiple helper functions or getters.
- Change Detection: Traditional change detection checks the entire form tree whenever any input changes, which can cause performance degradation in huge dashboards or complex wizards.
- Custom Form Controls: Implementing
ControlValueAccessorto make custom UI components (like a custom dropdown or rating input) work with forms is famously complex.
Enter Angular Signal Forms ⚡
Signal Forms are schema-first: you describe your data model and its validation rules once, then bind that schema to a signal holding the data. The framework generates the form tree for you.
- Declarative: All form states (e.g.,
value(),valid(),invalid(),dirty(),touched(),pending()) are read-only signals — just like computed signals you already know. - Type-safe: Schemas are generic over your data model. Typos in field names? Caught at compile time.
- Fine-grained Reactivity: Only templates reading specific signal states re-evaluate when values change — the same granular update model signals bring everywhere else.
- RxJS is Optional, Not Required: Built-in validators and sync validation need no streams at all. For async validation,
validateAsyncintegrates withrxResourceso the framework trackspending()for you — no manual subscription management.
The Core Abstractions
Signal Forms have three moving parts working together.
1. The Data Model
A TypeScript interface describing one record of your form data, plus a signal holding the current value.
interface Profile {
firstName: string;
lastName: string;
}
const profileData = signal<Profile>({ firstName: 'Shaik', lastName: 'Munsif' });
2. The Schema
A schema<T> declares validation rules. Each rule references a field via the path argument — fully type-safe, so path.firstName autocompletes from the interface.
import { schema, required } from '@angular/forms/signals';
const profileSchema = schema<Profile>((path) => {
required(path.firstName);
});
3. The Form Root
Pass the data signal and the schema to form(...). The result exposes one node per field in your interface, each of which is itself a callable returning reactive state.
import { form } from '@angular/forms/signals';
const profileForm = form(profileData, profileSchema);
// Read a field's value as a signal
console.log(profileForm.firstName().value()); // 'Shaik'
// Write by updating the underlying signal — no setValue boilerplate
profileData.set({ firstName: 'Munsif', lastName: 'Shaik' });
Building a Signal Form: Step-by-Step
Let's build a simple username input with declarative bindings.
Step 1 — Define the model and schema
import { Component, signal } from '@angular/core';
import { form, schema, required, FormField } from '@angular/forms/signals';
interface UsernameModel {
username: string;
}
const usernameSchema = schema<UsernameModel>((path) => {
required(path.username);
});
Step 2 — Create the form and bind the template
The [formRoot] directive connects the <form> element to the form root. The [formField] directive connects each input to its field node.
@Component({
selector: 'app-username-input',
imports: [FormField],
template: `
<div class="field-container">
<label for="username">Username</label>
<form [formRoot]="usernameForm">
<input
id="username"
[formField]="usernameForm.username"
placeholder="Enter your handle..."
/>
@if (usernameForm.username().touched() && usernameForm.username().invalid()) {
<span class="error">Username is required!</span>
}
<p>Current Value: {{ usernameForm.username().value() }}</p>
<p>Is Dirty: {{ usernameForm.username().dirty() }}</p>
</form>
</div>
`
})
export class UsernameInputComponent {
private data = signal<UsernameModel>({ username: '' });
usernameForm = form(this.data, usernameSchema);
}
Step 3 — Derive view state with computed()
Because every form state is a signal, you can build derived UI state the same way you would with any other signal — using computed(). (New to computed()? Read the Computed Signals section of our Signals guide.)
import { computed } from '@angular/core';
// Inside the component class
isSubmitDisabled = computed(() => this.profileForm.invalid() || this.profileForm.pending());
Nested Objects and Dynamic Arrays
Real forms have nested groups (like an address inside a profile) and dynamic lists (like multiple skills or phone numbers). Signal Forms model these as plain TypeScript nesting — arrays stay arrays, objects stay objects. The schema mirrors the shape.
Nested objects with apply
Use apply(path.subObject, subSchema) to attach a sub-schema to a nested object field.
import { Component, signal } from '@angular/core';
import { form, schema, apply, required, FormField } from '@angular/forms/signals';
interface AddressModel {
street: string;
city: string;
}
const addressSchema = schema<AddressModel>((path) => {
required(path.street);
required(path.city);
});
@Component({
selector: 'app-address-form',
imports: [FormField],
template: `
<form [formRoot]="addressForm">
<input [formField]="addressForm.street" placeholder="Street" />
<input [formField]="addressForm.city" placeholder="City" />
<button [disabled]="addressForm.invalid()">Save Address</button>
</form>
`
})
export class AddressFormComponent {
private data = signal<AddressModel>({ street: '', city: '' });
addressForm = form(this.data, addressSchema);
}
If AddressModel were nested inside a ProfileModel, you'd write apply(path.address, addressSchema) inside the parent schema.
Dynamic arrays with applyEach
Use applyEach(path.arrayField, itemSchema) to apply validation rules to every element of an array field. Adding or removing items is just mutating the underlying signal — no push, removeAt, or FormArray ceremony.
import { Component, signal } from '@angular/core';
import { form, schema, applyEach, required, FormField } from '@angular/forms/signals';
interface SkillsModel {
skills: string[];
}
const skillsSchema = schema<SkillsModel>((path) => {
applyEach(path.skills, schema<string>((leaf) => required(leaf)));
});
@Component({
selector: 'app-dynamic-skills',
imports: [FormField],
template: `
<div class="skills-box">
<h3>Add your skills</h3>
<form [formRoot]="skillsForm">
@for (skill of skillsForm.skills; track $index) {
<div class="skill-row">
<input [formField]="skill" />
<button type="button" (click)="removeSkill($index)">Remove ❌</button>
</div>
}
<button type="button" (click)="addSkill()">Add Skill ➕</button>
</form>
</div>
`
})
export class DynamicSkillsComponent {
private data = signal<SkillsModel>({ skills: ['Angular', 'Signals'] });
skillsForm = form(this.data, skillsSchema);
addSkill() {
this.data.update((d) => ({ skills: [...d.skills, ''] }));
}
removeSkill(index: number) {
this.data.update((d) => ({ skills: d.skills.filter((_, i) => i !== index) }));
}
}
Why this is nicer than Reactive Forms. You're mutating a plain signal array — the form tree tracks the diff automatically. No
FormArray.push/removeAt, no track-by identity, noclearValidators()calls when the list shrinks.
Declarative Validation in Signal Forms
Signal Forms support built-in validators, custom sync validators, and asynchronous validators. All three are declared inside the schema.
Built-in validators
The @angular/forms/signals package exports standalone validator functions — required, email, minLength, maxLength, min, max, pattern, and friends. Call them inside the schema callback, passing the relevant path.field.
import { schema, required, email, minLength } from '@angular/forms/signals';
const schema = schema<MyModel>((path) => {
required(path.email);
email(path.email);
minLength(path.password, 8);
});
Custom synchronous validators
Use validate(path.field, (ctx) => ...). The ctx exposes ctx.value() (the current field's value as a signal), ctx.state (full FieldState), and ctx.valueOf(path.to.sibling) for reading other fields by schema path — that's how cross-field validation works. Return null if valid, or an error object if invalid.
For rules that need to validate the relationship between several fields (e.g. password + confirm match), prefer validateTree(parentPath, (ctx) => ...) — its ctx.value() yields the entire parent model, and any error you return can target a specific child via the optional fieldTree property.
import { validate } from '@angular/forms/signals';
validate(path.password, (ctx) => {
const value = ctx.value();
if (!value) return null;
const hasUpperCase = /[A-Z]/.test(value);
const hasNumber = /[0-9]/.test(value);
return hasUpperCase && hasNumber && value.length >= 8
? null
: { weakPassword: 'Password must be 8+ chars with a number and an uppercase letter' };
});
Custom asynchronous validators
Async validators are declared with validateAsync. They integrate with Angular's rxResource so the framework tracks the loading state automatically — your form's pending() signal becomes true while the async work is in flight, and false when it settles.
import { validateAsync } from '@angular/forms/signals';
import { rxResource } from '@angular/core/rxjs-interop';
import { map, catchError, of } from 'rxjs';
validateAsync(path.email, {
params: (ctx) => ({ value: ctx.value() }),
factory: (params) =>
rxResource({
params,
stream: (p) =>
userService.checkEmailExists(p.params.value).pipe(
map((exists) => !exists),
catchError(() => of(false))
),
}),
onSuccess: (isAvailable) => (isAvailable ? null : { emailTaken: true }),
onError: () => ({ emailCheckFailed: true }),
});
Real-World Zero-to-Hero Example: User Registration Form
Let's tie everything together: nested groups (password + confirm), dynamic arrays (skills), cross-field validation (passwords match), and async validation (email lookup).
import { Component, computed, inject, signal } from '@angular/core';
import { FormField, form, schema, apply, applyEach, required, email, minLength, validate, validateAsync } from '@angular/forms/signals';
import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { map, catchError, of, delay } from 'rxjs';
interface SecurityModel {
password: string;
confirmPassword: string;
}
interface RegisterModel {
firstName: string;
lastName: string;
email: string;
security: SecurityModel;
skills: string[];
}
// Mock API service
class UserService {
private http = inject(HttpClient);
checkEmailTaken(email: string) {
return this.http
.get<{ taken: boolean }>(`/api/check-email?email=${encodeURIComponent(email)}`)
.pipe(
delay(800), // Simulate network latency
map((res) => res.taken),
catchError(() => of(false))
);
}
}
// Cross-field validator: confirm must match password.
// Attached to confirmPassword so the error surfaces there.
// ctx.valueOf(p.password) reads the sibling field's current value through the schema path.
const securitySchema = schema<SecurityModel>((p) => {
required(p.password);
minLength(p.password, 6);
required(p.confirmPassword);
validate(p.confirmPassword, (ctx) => {
const password = ctx.valueOf(p.password);
const confirm = ctx.value();
if (!confirm) return null; // `required` already handles the empty case
return password === confirm ? null : { kind: 'mismatch' };
});
});
const registerSchema = schema<RegisterModel>((path) => {
required(path.firstName);
required(path.email);
email(path.email);
// Async email lookup — pending() flips on/off automatically
validateAsync(path.email, {
params: (ctx) => ({ value: ctx.value() }),
factory: (params) =>
rxResource({
params,
stream: (p) =>
inject(UserService).checkEmailTaken(p.params.value).pipe(map((taken) => !taken)),
}),
onSuccess: (isAvailable) => (isAvailable ? null : { emailTaken: true }),
onError: () => ({ emailCheckFailed: true }),
});
// Nested security group
apply(path.security, securitySchema);
// Dynamic skills array
applyEach(path.skills, schema<string>((leaf) => required(leaf)));
});
@Component({
selector: 'app-register-form',
providers: [UserService],
imports: [FormField],
template: `
<div class="form-container">
<h2>Join our Community 🚀</h2>
<form [formRoot]="registerForm" (submit)="onSubmit()">
<!-- Name Fields -->
<div class="row">
<div class="field">
<label>First Name</label>
<input [formField]="registerForm.firstName" />
@if (registerForm.firstName().touched() && registerForm.firstName().invalid()) {
<small class="error">Required</small>
}
</div>
<div class="field">
<label>Last Name</label>
<input [formField]="registerForm.lastName" />
</div>
</div>
<!-- Email Field (with Async validation status indicator) -->
<div class="field">
<label>Email Address</label>
<input [formField]="registerForm.email" type="email" />
@if (registerForm.email().pending()) {
<span class="loading-indicator">Checking availability... ⏳</span>
}
@if (registerForm.email().touched() && registerForm.email().invalid()) {
@if (registerForm.email().getError('required'); as err) {
<small class="error">Email is required</small>
}
@if (registerForm.email().getError('email'); as err) {
<small class="error">Invalid email format</small>
}
@if (registerForm.email().getError('emailTaken'); as err) {
<small class="error">This email is already registered</small>
}
}
</div>
<!-- Passwords Nested Group -->
<div class="nested-group">
<div class="field">
<label>Password</label>
<input [formField]="registerForm.security.password" type="password" />
@if (
registerForm.security.password().touched() &&
registerForm.security.password().invalid()
) {
<small class="error">Password must be 6+ characters</small>
}
</div>
<div class="field">
<label>Confirm Password</label>
<input [formField]="registerForm.security.confirmPassword" type="password" />
@if (
registerForm.security.confirmPassword().touched() &&
registerForm.security.confirmPassword().getError('mismatch')
) {
<small class="error">Passwords do not match</small>
}
</div>
</div>
<!-- Dynamic Skills Array -->
<div class="array-section">
<label>Programming Languages / Skills</label>
@for (skill of registerForm.skills; track $index) {
<div class="array-row">
<input [formField]="skill" placeholder="e.g., Angular" />
<button type="button" (click)="removeSkill($index)">❌</button>
</div>
}
<button type="button" class="btn-sec" (click)="addSkill()">Add Skill +</button>
</div>
<!-- Submit Button -->
<button type="submit" [disabled]="formStateInvalid()" class="btn-primary">
@if (registerForm.pending()) {
Submitting...
} @else {
Register Account
}
</button>
</form>
</div>
`
})
export class RegisterFormComponent {
private data = signal<RegisterModel>({
firstName: '',
lastName: '',
email: '',
security: { password: '', confirmPassword: '' },
skills: ['Angular', 'TypeScript'],
});
registerForm = form(this.data, registerSchema);
formStateInvalid = computed(
() => this.registerForm.invalid() || this.registerForm.pending()
);
addSkill() {
this.data.update((d) => ({ ...d, skills: [...d.skills, ''] }));
}
removeSkill(index: number) {
this.data.update((d) => ({ ...d, skills: d.skills.filter((_, i) => i !== index) }));
}
onSubmit() {
if (this.registerForm.invalid()) return;
// Read the typed payload directly from the underlying signal
const payload = this.data();
console.log('Sending payload:', payload);
// {
// firstName: 'Shaik',
// lastName: 'Munsif',
// email: 'contact@munsifshaik.com',
// security: { password: '***', confirmPassword: '***' },
// skills: ['Angular', 'TypeScript']
// }
}
}
Comparison: Reactive Forms vs Signal Forms
| Feature | Reactive Forms | Signal Forms |
|---|---|---|
| Reactivity Primitive | RxJS Observables (valueChanges) | Angular Signals (value()) |
| Change Detection | Full component checks | Fine-grained, template-targeted |
| Type Safety | Strict (since Angular 14) | Strict, inferred from your TS model |
| Boilerplate | High (getters, async pipes, subscriptions) | Low (read signals directly in template) |
| Custom Components | Requires ControlValueAccessor | Bind to writable signals or model() |
| Async state | Available via status === 'PENDING' (string check) | Native signal (pending()) that's fine-grained and composable |
| Dynamic arrays | FormArray.push / removeAt / clearValidators | Mutate the underlying signal array; framework tracks the diff |
Conclusion
Signal Forms represent a paradigm shift in how Angular developers interact with user input. Gone are the days of valueChanges.subscribe(), takeUntil(destroy$), and as FormArray type casts. Instead, you describe your data model and rules once in a schema, point a signal at it, and let the framework expose reactive, fully-typed state.
When combining Signal Forms with the Resource API for data fetching, you gain a complete toolchain for handling input state, validations, API requests, and form submissions — all natively reactive, all signal-powered.
For the foundations of how signal(), computed(), and effect() work under the hood, read our Angular Signals Complete Guide. Happy coding! 🚀
🧠 Test Your Knowledge
Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.