Signal Forms (Angular 21)
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?
- ✅ 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
| 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
- Component
- vs Reactive Forms
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
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
<!-- 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
- required
- min / max
- minLength / maxLength
- pattern
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
Synchronous Validators
- Custom Validator
- Template
- Multiple Validators
import { form, required, validate } from '@angular/forms/signals';
// Custom validator function
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({...})
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 functions
function 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 validators
albumForm = form(
signal({ price: 0 }),
(path) => {
required(path.price);
validate(path.price, (ctx) => isPositive(ctx.value()));
validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
}
);
Async Validators
Use validateAsync for server-side validation:
- Service
- Component with Async Validation
- Template
@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
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
// Get field value
const name = this.albumForm.name().value();
// Check field validity
const isNameValid = this.albumForm.name().valid();
// Check if field is dirty
const isNameDirty = this.albumForm.name().dirty();
// Check if field is touched
const isNameTouched = this.albumForm.name().touched();
// Get field errors
const nameErrors = this.albumForm.name().errors();
Managing Dynamic Fields (Tags)
Signal Forms don't have a FormArray equivalent yet. Use separate signals for dynamic fields:
- Component
- Pattern Explanation
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
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
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
- ✅ 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
- Reactive Forms (v16)
- Signal Forms (v21)
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
- Reactive Forms - Traditional reactive forms (v16)
- Signals - Signal fundamentals
- httpResource - For async validation
- Album Model - Data model reference
- Guards - CanDeactivate guard
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