Skip to main content

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

FeatureReactive Forms (v16)Signal Forms (v21)
APIFormGroup, FormControlform(), Field
StateObservable-basedSignal-based
ValidatorsValidators classFunctions (required, min, validate)
Template bindingformControlName[field]
Type safetyPartialFull
BoilerplateHighLow
Learning curveSteepGentle

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);
}
}
}

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

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>
}
}

Custom Validators

Synchronous 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()));
}
);
}

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);
}
}

validateAsync pattern:

  1. params - Extract values to validate
  2. factory - HTTP resource or observable
  3. onSuccess - Return error or undefined
  4. onError - 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:

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);
}
}
}

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() not albumForm
  • ✅ Use separate signals for dynamic arrays (no FormArray yet)
  • ✅ Combine form value with other signals on submit
  • ✅ Use validateAsync with httpResource for server validation
  • ✅ Check dirty() before navigation warnings
  • ❌ Don't mix with Reactive Forms in same component
  • ❌ Don't forget to import Field directive

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>


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