Signal Formulaires (Angular 21)
Qu’est-ce que les Signal Forms ?
Section intitulée « 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 ?
Section intitulée « 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
Section intitulée « 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
Section intitulée « Utilisation de base »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
Section intitulée « Template Binding »Field Directive
Section intitulée « 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
Section intitulée « 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
Section intitulée « 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 intitulée « Custom Validators »Synchronous Validators
Section intitulée « Synchronous Validators »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 validateursfunction 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 validateursalbumForm = 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 intitulée « Async Validators »Utiliser validateAsync pour la validation côté serveur :
@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
Section intitulée « Form State »Accessing Form State
Section intitulée « 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 intitulée « 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();
// Vérifier si le champ a été touchéconst isNameTouched = this.albumForm.name().touched();
// Get field errorsconst nameErrors = this.albumForm.name().errors();Managing Dynamic Fields (Tags)
Section intitulée « Managing Dynamic Fields (Tags) »Les Signal Forms n’ont pas encore d’équivalent à FormArray. Utiliser des signaux séparés pour les champs dynamiques :
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
Section intitulée « 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
Section intitulée « 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
Section intitulée « 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
Section intitulée « Migration depuis 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 intitulée « 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 intitulée « 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

