Signal Forms (Angular 21)
What are Signal Forms?
Section titled “What are Signal Forms?”Signal Forms are a new reactive forms API introduced in Angular 21 that replaces FormGroup, FormControl, and FormBuilder with a signal-based approach. They provide a simpler, more type-safe way to handle forms.
Why Use Signal Forms?
Section titled “Why Use Signal Forms?”- ✅ Signal-based - Fully reactive with Angular signals
- ✅ Type-safe - Better TypeScript inference
- ✅ Simpler API - No FormGroup/FormControl boilerplate
- ✅ Declarative - Define form and validators together
- ✅ Better DX - Cleaner, more readable code
- ✅ Built-in validation - Sync and async validators
Signal Forms vs Reactive Forms
Section titled “Signal Forms vs Reactive Forms”| Feature | Reactive Forms (v16) | Signal Forms (v21) |
|---|---|---|
| API | FormGroup, FormControl | form(), Field |
| State | Observable-based | Signal-based |
| Validators | Validators class | Functions (required, min, validate) |
| Template binding | formControlName |
[field] |
| Type safety | Partial | Full |
| Boilerplate | High | Low |
| Learning curve | Steep | Gentle |
Basic Usage
Section titled “Basic Usage”import { Component, signal } from '@angular/core';import { form, Field, required, min } from '@angular/forms/signals';import { Album } from '../models/album.model';
@Component({ selector: 'app-album-form', standalone: true, imports: [Field], template: ` <form> <label> Album Name: <input [field]="albumForm.name"> </label> @for (error of albumForm.name().errors(); track error.kind) { @if (error.kind === 'required') { <p class="error">Album name is required</p> } }
<label> Price: <input [field]="albumForm.price" type="number"> </label> @for (error of albumForm.price().errors(); track error.kind) { @if (error.kind === 'required') { <p class="error">Price is required</p> } @if (error.kind === 'min') { <p class="error">Price must be at least 0</p> } }
<button (click)="saveAlbum()" [disabled]="!albumForm().valid()"> Save Album </button> </form> `})export class AlbumFormComponent { // Create signal form with initial values and validators albumForm = form( signal<Album>({ id: 0, name: '', artist: '', description: '', price: 0, tags: [] }), (path) => { // Apply validators required(path.name); required(path.artist); required(path.price); min(path.price, 0); } );
saveAlbum() { if (this.albumForm().valid()) { const albumData = this.albumForm().value(); console.log('Saving album:', albumData); } }}Reactive Forms (Old):
import { FormBuilder, Validators } from '@angular/forms';
export class AlbumFormComponent { fb = inject(FormBuilder);
albumForm = this.fb.group({ name: ['', [Validators.required]], artist: ['', [Validators.required]], price: [0, [Validators.required, Validators.min(0)]] });
saveAlbum() { if (this.albumForm.valid) { const albumData = this.albumForm.value; console.log('Saving album:', albumData); } }}Signal Forms (New):
import { form, required, min } from '@angular/forms/signals';
export class AlbumFormComponent { albumForm = form( signal({ name: '', artist: '', price: 0 }), (path) => { required(path.name); required(path.artist); required(path.price); min(path.price, 0); } );
saveAlbum() { if (this.albumForm().valid()) { const albumData = this.albumForm().value(); console.log('Saving album:', albumData); } }}Key differences:
- ✅ No FormBuilder needed
- ✅ No Validators class
- ✅ Validators applied as functions
- ✅ Form accessed as signal:
albumForm() - ✅ Better TypeScript inference
Template Binding
Section titled “Template Binding”Field Directive
Section titled “Field Directive”Use the [field] directive to bind inputs to form fields:
<input [field]="albumForm.name"><input [field]="albumForm.price" type="number"><textarea [field]="albumForm.description"></textarea>The [field] directive:
- Automatically binds value
- Handles change events
- Updates form state
- Provides error signals
Accessing Field State
Section titled “Accessing Field State”<!-- Field value --><p>Current name: {{ albumForm.name().value() }}</p>
<!-- Field errors -->@for (error of albumForm.name().errors(); track error.kind) { <p class="error">{{ error.message }}</p>}
<!-- Field validity -->@if (albumForm.name().valid()) { <p>✓ Valid</p>}
<!-- Field dirty state -->@if (albumForm.name().dirty()) { <p>Field has been modified</p>}
<!-- Field touched state -->@if (albumForm.name().touched()) { <p>Field has been focused</p>}Built-in Validators
Section titled “Built-in Validators”import { form, required } from '@angular/forms/signals';
albumForm = form( signal({ name: '', artist: '' }), (path) => { required(path.name); required(path.artist); });@for (error of albumForm.name().errors(); track error.kind) { @if (error.kind === 'required') { <p class="error">Album name is required</p> }}import { form, min, max } from '@angular/forms/signals';
albumForm = form( signal({ price: 0, quantity: 1 }), (path) => { min(path.price, 0); max(path.quantity, 100); });@for (error of albumForm.price().errors(); track error.kind) { @if (error.kind === 'min') { <p class="error">Price must be at least 0</p> }}
@for (error of albumForm.quantity().errors(); track error.kind) { @if (error.kind === 'max') { <p class="error">Quantity cannot exceed 100</p> }}import { form, minLength, maxLength } from '@angular/forms/signals';
albumForm = form( signal({ name: '', description: '' }), (path) => { minLength(path.name, 2); maxLength(path.description, 500); });@for (error of albumForm.name().errors(); track error.kind) { @if (error.kind === 'minLength') { <p class="error"> Name must be at least {{ error.minLength }} characters </p> }}import { form, pattern } from '@angular/forms/signals';
albumForm = form( signal({ email: '', zipCode: '' }), (path) => { pattern(path.email, /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); pattern(path.zipCode, /^\d{5}$/); });@for (error of albumForm.email().errors(); track error.kind) { @if (error.kind === 'pattern') { <p class="error">Invalid email format</p> }}Custom Validators
Section titled “Custom Validators”Synchronous Validators
Section titled “Synchronous Validators”import { form, required, validate } from '@angular/forms/signals';
// Custom validator functionfunction priceEndsWithNine(value: number) { if (!value) return null;
const lastDigit = value.toString().charAt(value.toString().length - 1);
return lastDigit !== '9' ? { kind: 'priceEndingWith9', message: 'Price must end with 9' } : null;}
@Component({...})export class AlbumFormComponent { albumForm = form( signal({ name: '', price: 0 }), (path) => { required(path.name); required(path.price);
// Apply custom validator validate(path.price, (ctx) => priceEndsWithNine(ctx.value())); } );}<form> <label> Price: <input [field]="albumForm.price" type="number"> </label>
@for (error of albumForm.price().errors(); track error.kind) { @if (error.kind === 'required') { <p class="error">Price is required</p> } @if (error.kind === 'priceEndingWith9') { <p class="error">{{ error.message }}</p> } }
<button [disabled]="!albumForm().valid()">Save</button></form>// Validator functionsfunction priceEndsWithNine(value: number) { const lastDigit = value.toString().slice(-1); return lastDigit !== '9' ? { kind: 'priceEndingWith9', message: 'Price must end with 9' } : null;}
function isPositive(value: number) { return value <= 0 ? { kind: 'positive', message: 'Value must be positive' } : null;}
// Apply multiple validatorsalbumForm = form( signal({ price: 0 }), (path) => { required(path.price); validate(path.price, (ctx) => isPositive(ctx.value())); validate(path.price, (ctx) => priceEndsWithNine(ctx.value())); });Async Validators
Section titled “Async Validators”Use validateAsync for server-side validation:
@Injectable({ providedIn: 'root' })export class AlbumService { http = inject(HttpClient);
// HTTP Resource for async validation albumByArtistAndNameResource() { return httpResource<Album[]>(() => { return 'http://localhost:3000/albums'; }, { defaultValue: [] }); }
save(album: Album): Observable<Album> { return this.http.post<Album>('http://localhost:3000/albums', album); }}import { form, required, validateAsync } from '@angular/forms/signals';
@Component({...})export class AlbumFormComponent { private albumService = inject(AlbumService);
albumForm = form( signal<Album>({ id: 0, name: '', artist: '', description: '', price: 0, tags: [] }), (path) => { // Sync validators required(path.name); required(path.artist); required(path.price);
// Async validator: check if album exists validateAsync(path, { params: (ctx) => ctx.value(), factory: () => this.albumService.albumByArtistAndNameResource(), onSuccess: (albums) => { const { artist, name } = this.albumForm().value(); if (!artist || !name || !albums) return undefined;
// Check if album exists const exists = albums.some((a: Album) => a.name.toLowerCase() === name.toLowerCase() && a.artist.toLowerCase() === artist.toLowerCase() );
return exists ? { kind: 'albumExists', message: 'Album already exists' } : undefined; }, onError: () => ({ kind: 'albumCheckFailed', message: 'Could not verify album' }) }); } );
hasAlbumExistsError() { return this.albumForm().errors().some((e: any) => e.kind === 'albumExists'); }}<form> @if (hasAlbumExistsError()) { <div class="error-banner"> ⚠️ An album with this artist and name already exists </div> }
<label> Album Name: <input [field]="albumForm.name"> </label>
<label> Artist: <input [field]="albumForm.artist"> </label>
<!-- Form-level async validation happens here --> @if (albumForm().status() === 'PENDING') { <p>Checking if album exists...</p> }
<button [disabled]="!albumForm().valid()" (click)="saveAlbum()"> Save Album </button></form>validateAsync pattern:
params- Extract values to validatefactory- HTTP resource or observableonSuccess- Return error or undefinedonError- Handle HTTP errors
Form State
Section titled “Form State”Accessing Form State
Section titled “Accessing Form State”export class AlbumFormComponent { albumForm = form( signal({ name: '', price: 0 }), (path) => { required(path.name); min(path.price, 0); } );
checkFormState() { // Get form value const value = this.albumForm().value(); console.log('Form value:', value);
// Check validity const isValid = this.albumForm().valid(); console.log('Is valid:', isValid);
// Check dirty state const isDirty = this.albumForm().dirty(); console.log('Is dirty:', isDirty);
// Get errors const errors = this.albumForm().errors(); console.log('Form errors:', errors);
// Get status const status = this.albumForm().status(); // 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED' }}Field-Level State
Section titled “Field-Level State”// Get field valueconst name = this.albumForm.name().value();
// Check field validityconst isNameValid = this.albumForm.name().valid();
// Check if field is dirtyconst isNameDirty = this.albumForm.name().dirty();
// Check if field is touchedconst isNameTouched = this.albumForm.name().touched();
// Get field errorsconst nameErrors = this.albumForm.name().errors();Managing Dynamic Fields (Tags)
Section titled “Managing Dynamic Fields (Tags)”Signal Forms don’t have a FormArray equivalent yet. Use separate signals for dynamic fields:
import { Component, signal } from '@angular/core';import { form, Field, required } from '@angular/forms/signals';
@Component({ selector: 'app-album-form', standalone: true, imports: [Field], template: ` <form> <input [field]="albumForm.name" placeholder="Album name">
<div class="tags-section"> <h3>Tags</h3> @for (tag of tags(); track $index; let i = $index) { <div class="tag-item"> <input [value]="tag" (input)="updateTag(i, $any($event.target).value)" placeholder="Tag {{ i + 1 }}"> <button type="button" (click)="removeTag(i)"> Remove </button> </div> } <button type="button" (click)="addTag()"> Add Tag </button> </div>
<button (click)="saveAlbum()" [disabled]="!albumForm().valid()"> Save </button> </form> `})export class AlbumFormComponent { // Main form albumForm = form( signal({ id: 0, name: '', artist: '', price: 0 }), (path) => { required(path.name); required(path.artist); } );
// Separate signal for tags tags = signal<string[]>([]);
addTag() { this.tags.update(tags => [...tags, '']); }
removeTag(index: number) { this.tags.update(tags => tags.filter((_, i) => i !== index)); }
updateTag(index: number, value: string) { this.tags.update(tags => { const newTags = [...tags]; newTags[index] = value; return newTags; }); }
saveAlbum() { if (this.albumForm().valid()) { const albumData = { ...this.albumForm().value(), tags: this.tags() }; console.log('Saving:', albumData); } }}Why use separate signal for tags?
- Signal Forms don’t have
FormArrayyet - Use a signal for array management
- Combine with main form on save
Pattern:
- Create main form with
form() - Create separate
tags = signal<string[]>([]) - Manage tags with
update() - Merge on save:
{ ...form.value(), tags: tags() }
Benefits:
- ✅ Simple array management
- ✅ Reactive updates
- ✅ Type-safe
- ✅ Works with new control flow
CanDeactivate Guard with Signal Forms
Section titled “CanDeactivate Guard with Signal Forms”Prevent navigation with unsaved changes:
import { Injectable } from '@angular/core';import { CanDeactivate } from '@angular/router';import { Observable, of } from 'rxjs';
export interface CanComponentDeactivate { canDeactivate: () => Observable<boolean> | boolean;}
@Injectable({ providedIn: 'root' })export class FormDeactivateGuard implements CanDeactivate<CanComponentDeactivate> { canDeactivate(component: CanComponentDeactivate): Observable<boolean> | boolean { return component.canDeactivate(); }}@Component({...})export class AlbumFormComponent implements CanComponentDeactivate { private dialog = inject(MatDialog); private formSubmitted = signal(false);
albumForm = form( signal({ name: '', artist: '', price: 0 }), (path) => { required(path.name); } );
canDeactivate(): Observable<boolean> { // Allow navigation if form not dirty or already submitted if (!this.albumForm().dirty() || this.formSubmitted()) { return of(true); }
// Show confirmation dialog return this.dialog.open(ConfirmDialogComponent, { data: { title: 'Unsaved Changes', message: 'You have unsaved changes. Do you really want to leave?' } }).afterClosed(); }
saveAlbum() { if (this.albumForm().valid()) { this.formSubmitted.set(true); // Save logic... } }}Complete Example
Section titled “Complete Example”Real-world form from step-18:
import { Component, inject, signal } from '@angular/core';import { Field, form, min, required, validate, validateAsync } from '@angular/forms/signals';import { AlbumService } from './services/album.service';import { Album } from './models/album.model';
function priceEndsWithNine(value: number) { if (!value) return null; const lastDigit = value.toString().charAt(value.toString().length - 1); return lastDigit !== '9' ? { kind: 'priceEndingWith9', message: 'Price must end with 9' } : null;}
@Component({ selector: 'app-album-form', standalone: true, imports: [Field], template: ` <h2>Add Album (Signal Forms)</h2>
@if (hasAlbumExistsError()) { <div class="error-banner"> ⚠️ Album already exists </div> }
<form> <label> Album Name: <input [field]="albumForm.name"> @for (error of albumForm.name().errors(); track error.kind) { @if (error.kind === 'required') { <span class="error">Required</span> } } </label>
<label> Artist: <input [field]="albumForm.artist"> @for (error of albumForm.artist().errors(); track error.kind) { @if (error.kind === 'required') { <span class="error">Required</span> } } </label>
<label> Price: <input [field]="albumForm.price" type="number"> @for (error of albumForm.price().errors(); track error.kind) { @if (error.kind === 'required') { <span class="error">Required</span> } @if (error.kind === 'min') { <span class="error">Must be at least 0</span> } @if (error.kind === 'priceEndingWith9') { <span class="error">{{ error.message }}</span> } } </label>
<label> Description: <textarea [field]="albumForm.description"></textarea> </label>
<div class="tags-section"> <h3>Tags</h3> @for (tag of tags(); track $index; let i = $index) { <div class="tag-item"> <input [value]="tag" (input)="updateTag(i, $any($event.target).value)"> <button type="button" (click)="removeTag(i)">Remove</button> </div> } <button type="button" (click)="addTag()">Add Tag</button> </div>
<button type="button" (click)="saveAlbum()" [disabled]="!albumForm().valid()"> Save Album </button> </form> `})export class AlbumFormComponent { private albumService = inject(AlbumService); private router = inject(Router); private formSubmitted = signal(false);
tags = signal<string[]>([]);
albumForm = form( signal<Album>({ id: 0, name: '', artist: '', description: '', price: 0, tags: [] }), (path) => { // Required validators required(path.name); required(path.artist); required(path.price); min(path.price, 0);
// Custom sync validator validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
// Async validator validateAsync(path, { params: (ctx) => ctx.value(), factory: () => this.albumService.albumByArtistAndNameResource(), onSuccess: (albums) => { const { artist, name } = this.albumForm().value(); if (!artist || !name || !albums) return undefined;
const exists = albums.some((a: Album) => a.name.toLowerCase() === name.toLowerCase() && a.artist.toLowerCase() === artist.toLowerCase() );
return exists ? { kind: 'albumExists', message: 'Album already exists' } : undefined; }, onError: () => ({ kind: 'albumCheckFailed', message: 'Could not verify album' }) }); } );
hasAlbumExistsError() { return this.albumForm().errors().some((e: any) => e.kind === 'albumExists'); }
addTag() { this.tags.update(tags => [...tags, '']); }
removeTag(index: number) { this.tags.update(tags => tags.filter((_, i) => i !== index)); }
updateTag(index: number, value: string) { this.tags.update(tags => { const newTags = [...tags]; newTags[index] = value; return newTags; }); }
saveAlbum() { if (this.albumForm().valid()) { this.formSubmitted.set(true); const albumData = { ...this.albumForm().value(), tags: this.tags() } as Album;
this.albumService.save(albumData).subscribe(() => { this.router.navigate(['/albums']); }); } }}Best Practices
Section titled “Best Practices”- ✅ Use
form()instead of FormGroup/FormBuilder - ✅ Apply validators in the path callback
- ✅ Use
[field]directive for template binding - ✅ Access form as signal:
albumForm()notalbumForm - ✅ Use separate signals for dynamic arrays (no FormArray yet)
- ✅ Combine form value with other signals on submit
- ✅ Use
validateAsyncwith httpResource for server validation - ✅ Check
dirty()before navigation warnings - ❌ Don’t mix with Reactive Forms in same component
- ❌ Don’t forget to import
Fielddirective
Migration from Reactive Forms
Section titled “Migration from Reactive Forms”import { FormBuilder, Validators } from '@angular/forms';
export class AlbumFormComponent { fb = inject(FormBuilder);
albumForm = this.fb.group({ name: ['', [Validators.required, Validators.minLength(2)]], artist: ['', Validators.required], price: [0, [Validators.required, Validators.min(0)]] });
get name() { return this.albumForm.get('name'); }
saveAlbum() { if (this.albumForm.valid) { const album = this.albumForm.value; // Save... } }}<form [formGroup]="albumForm"> <input formControlName="name"> <div *ngIf="name?.errors?.['required']"> Name is required </div></form>import { form, required, min, minLength } from '@angular/forms/signals';
export class AlbumFormComponent { albumForm = form( signal({ name: '', artist: '', price: 0 }), (path) => { required(path.name); minLength(path.name, 2); required(path.artist); required(path.price); min(path.price, 0); } );
saveAlbum() { if (this.albumForm().valid()) { const album = this.albumForm().value(); // Save... } }}<form> <input [field]="albumForm.name"> @for (error of albumForm.name().errors(); track error.kind) { @if (error.kind === 'required') { <div>Name is required</div> } }</form>Related Documentation
Section titled “Related Documentation”- Reactive Forms - Traditional reactive forms (v16)
- Signals - Signal fundamentals
- httpResource - For async validation
- Album Model - Data model reference
- Guards - CanDeactivate guard
Project Reference
Section titled “Project Reference”See this pattern in action:
- Component:
src/app/components/albums/album-add-signal/album-add-signal.component.ts - Template:
src/app/components/albums/album-add-signal/album-add-signal.component.html - Learning Path: Day 3, Module 3.7 - Signal Forms
Last Updated: December 2024 Angular Version: 21+ Status: New API, recommended for new projects

