Signal Formulaires (Angular 21)
Qu'est-ce que les Signal Forms ?
Signal Forms est une nouvelle API de formulaires réactifs introduite dans Angular 21 qui remplace FormGroup, FormControl et FormBuilder par une approche basée sur les signaux. Ils offrent une manière plus simple et plus sûre au niveau des types pour gérer les formulaires.
Pourquoi utiliser les Signal Forms ?
- ✅ Basé sur les signaux - Entièrement réactif avec les signaux Angular
- ✅ Type-safe - Meilleure inférence TypeScript
- ✅ API plus simple - Pas de boilerplate FormGroup/FormControl
- ✅ Déclaratif - Définir le formulaire et les validateurs ensemble
- ✅ Meilleure DX - Code plus propre et plus lisible
- ✅ Validation intégrée - Validateurs synchrones et asynchrones
Signal Forms vs Reactive Forms
| Fonctionnalité | Reactive Forms (v16) | Signal Forms (v21) |
|---|---|---|
| API | FormGroup, FormControl | form(), Field |
| État | Basé sur les observables | Basé sur les signaux |
| Validateurs | Classe Validators | Fonctions (required, min, validate) |
| Binding template | formControlName | [field] |
| Sécurité des types | Partielle | Complète |
| Boilerplate | Élevé | Faible |
| Courbe d'apprentissage | Raide | Douce |
Utilisation de base
- 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 {
// Créer un formulaire signal avec les valeurs initiales et les validateurs
albumForm = form(
signal<Album>({
id: 0,
name: '',
artist: '',
description: '',
price: 0,
tags: []
}),
(path) => {
// Appliquer les validateurs
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 (Ancien):
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);
}
}
}
Différences clés :
- ✅ Pas besoin de FormBuilder
- ✅ Pas de classe Validators
- ✅ Validateurs appliqués en tant que fonctions
- ✅ Formulaire accessible en tant que signal :
albumForm() - ✅ Meilleure inférence TypeScript
Template Binding
Field Directive
Utiliser la directive [field] pour lier les inputs aux champs du formulaire :
<input [field]="albumForm.name">
<input [field]="albumForm.price" type="number">
<textarea [field]="albumForm.description"></textarea>
La directive [field] :
- Lie automatiquement la valeur
- Gère les événements de changement
- Met à jour l'état du formulaire
- Fournit les signaux d'erreur
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>
}
<!-- État touched du champ -->
@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
- Validateur personnalisé
- Template
- Plusieurs validateurs
import { form, required, validate } from '@angular/forms/signals';
// Fonction de validateur personnalisé
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);
// Appliquer le validateur personnalisé
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>
// Fonctions de validateurs
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;
}
// Appliquer plusieurs validateurs
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
Utiliser validateAsync pour la validation côté serveur :
- Service
- Composant avec validation asynchrone
- 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) => {
// Validateurs synchrones
required(path.name);
required(path.artist);
required(path.price);
// Validateur asynchrone : vérifier si l'album existe
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>
Pattern validateAsync :
params- Extraire les valeurs à validerfactory- Ressource HTTP ou observableonSuccess- Retourner une erreur ou undefinedonError- Gérer les erreurs HTTP
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();
// Vérifier si le champ a été touché
const isNameTouched = this.albumForm.name().touched();
// Get field errors
const nameErrors = this.albumForm.name().errors();
Managing Dynamic Fields (Tags)
Les Signal Forms n'ont pas encore d'équivalent à FormArray. Utiliser des signaux séparés pour les champs dynamiques :
- 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);
}
}
}
Pourquoi utiliser un signal séparé pour les tags ?
- Les Signal Forms n'ont pas encore
FormArray - Utiliser un signal pour la gestion des tableaux
- Combiner avec le formulaire principal lors de la sauvegarde
Pattern :
- Créer le formulaire principal avec
form() - Créer un signal séparé
tags = signal<string[]>([]) - Gérer les tags avec
update() - Fusionner lors de la sauvegarde :
{ ...form.value(), tags: tags() }
Avantages :
- ✅ Gestion simple des tableaux
- ✅ Mises à jour réactives
- ✅ Type-safe
- ✅ Fonctionne avec le nouveau control flow
CanDeactivate Guard avec les Signal Forms
Empêcher la navigation avec des changements non sauvegardés :
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...
}
}
}
Exemple complet
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) => {
// Validateurs obligatoires
required(path.name);
required(path.artist);
required(path.price);
min(path.price, 0);
// Validateur synchrone personnalisé
validate(path.price, (ctx) => priceEndsWithNine(ctx.value()));
// Validateur asynchrone
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']);
});
}
}
}
Bonnes Pratiques
- ✅ Utiliser
form()au lieu de FormGroup/FormBuilder - ✅ Appliquer les validateurs dans le callback path
- ✅ Utiliser la directive
[field]pour le binding du template - ✅ Accéder au formulaire en tant que signal :
albumForm()et nonalbumForm - ✅ Utiliser des signaux séparés pour les tableaux dynamiques (pas encore de FormArray)
- ✅ Combiner la valeur du formulaire avec d'autres signaux lors de la soumission
- ✅ Utiliser
validateAsyncavec httpResource pour la validation serveur - ✅ Vérifier
dirty()avant les avertissements de navigation - ❌ Ne pas mélanger avec Reactive Forms dans le même composant
- ❌ Ne pas oublier d'importer la directive
Field
Migration depuis 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