Signal Forms dans Angular 22 : la révolution des formulaires, avant/après
Les formulaires dans Angular ont toujours été un sujet… disons, riche. Entre Reactive Forms (puissants mais verbeux) et Template-Driven Forms (simples mais limités), le développeur devait choisir son poison. Avec Signal Forms, Angular 22 propose une troisième voie qui combine le meilleur des deux mondes, en y ajoutant la réactivité fine des signaux. Voyons concrètement ce que ça change.
Le formulaire simple : inscription
Avant — Reactive Forms
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-signup',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" placeholder="Email" />
<div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
<span *ngIf="form.get('email')?.errors?.['required']">Email requis</span>
<span *ngIf="form.get('email')?.errors?.['email']">Email invalide</span>
</div>
<input type="password" formControlName="password" placeholder="Mot de passe" />
<div *ngIf="form.get('password')?.invalid && form.get('password')?.touched">
<span *ngIf="form.get('password')?.errors?.['required']">Mot de passe requis</span>
<span *ngIf="form.get('password')?.errors?.['minlength']">
Minimum {{ form.get('password')?.errors?.['minlength'].requiredLength }} caractères
</span>
</div>
<button type="submit" [disabled]="form.invalid">S'inscrire</button>
</form>
`,
imports: [ReactiveFormsModule]
})
export class SignupComponent {
form: FormGroup;
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
}
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}
Problèmes :
- Accès aux contrôles par chaînes de caractères (
form.get('email')) — pas de vérification à la compilation. - Messages d’erreur qui nécessitent des vérifications multiples et fragiles.
- Verbeux :
FormGroup,FormBuilder,Validators, binding[formGroup],formControlName… - Typage faible :
form.valueretourneanyou unPartial.
Après — Signal Forms
import { Component, signal } from '@angular/core';
import { form, FormField, required, email, minLength } from '@angular/forms/signals';
@Component({
selector: 'app-signup',
template: `
<form (submit)="onSubmit($event)">
<input type="email" [formField]="f.email" placeholder="Email" />
@if (f.email().invalid() && f.email().touched()) {
<div>
@for (error of f.email().errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
<input type="password" [formField]="f.password" placeholder="Mot de passe" />
@if (f.password().invalid() && f.password().touched()) {
<div>
@for (error of f.password().errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
<button type="submit" [disabled]="f().invalid()">S'inscrire</button>
</form>
`,
imports: [FormField]
})
export class SignupComponent {
readonly model = signal({ email: '', password: '' });
readonly f = form(this.model, schema => {
required(schema.email, { message: 'Email requis' });
email(schema.email, { message: 'Format email invalide' });
required(schema.password, { message: 'Mot de passe requis' });
minLength(schema.password, {
message: () => `Minimum 8 caractères`,
value: 8
});
});
onSubmit(event: Event) {
event.preventDefault();
if (this.f().valid()) {
console.log(this.f().value());
// TypeScript infère automatiquement : { email: string; password: string }
}
}
}
Ce qui change :
- Typage fort :
f().value()retourne exactement le type du modèle, pas d’any. - Plus de chaînes magiques :
f.emailau lieu deform.get('email'). L’IDE autocomplète. - Messages d’erreur embarqués : les validateurs déclarent leur propre message. Les erreurs sont itérables.
- Syntaxe unifiée : un seul
form(), un seul concept. - Contrôle flow moderne :
@if,@forà la place de*ngIf,*ngFor.
Et par rapport à ngModel (Template-Driven Forms) ? Mêmes gains. Avec ngModel, la validation vit dans les attributs HTML (required, email), les messages d’erreur sont à parser manuellement, le typage est inexistant, et l’état loading/erreur pour l’asynchrone est une usine à gaz. Signal Forms unifie tout ça : que vous veniez de Reactive Forms ou de ngModel, le constat est le même.
Validation croisée : confirmation de mot de passe
Avant — Reactive Forms
this.form = this.fb.group({
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['']
}, { validators: this.passwordsMatch });
// ...
passwordsMatch(group: FormGroup) {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { mismatch: true };
}
Le validateur est défini à part, il faut accéder aux contrôles par chaînes, et les erreurs apparaissent sur le groupe, pas sur le champ concerné.
Après — Signal Forms
this.f = form(this.model, (schema, checker) => {
required(schema.password, { message: 'Mot de passe requis' });
required(schema.confirmPassword, { message: 'Confirmation requise' });
// Validation croisée déclarée directement sur le champ
checker(schema.confirmPassword, (value, formValue) => {
if (value !== formValue.password) {
return { kind: 'mismatch', message: 'Les mots de passe ne correspondent pas' };
}
return null;
});
});
La validation croisée est déclarée sur le champ concerné, avec accès complet à la valeur du formulaire via formValue. L’erreur est localisée, pas noyée sur le groupe.
Champs dynamiques : liste de compétences
Avant — Reactive Forms avec FormArray
this.form = this.fb.group({
name: ['', Validators.required],
skills: this.fb.array([])
});
get skills(): FormArray {
return this.form.get('skills') as FormArray;
}
addSkill() {
this.skills.push(this.fb.control('', Validators.required));
}
removeSkill(index: number) {
this.skills.removeAt(index);
}
Dans le template :
<div *ngFor="let skill of skills.controls; let i = index" [formGroupName]="i"> <input [formControlName]="skill" /> <button (click)="removeSkill(i)">Supprimer</button> </div>
Après — Signal Forms
import { applyEach } from '@angular/forms/signals';
this.f = form(this.model, schema => {
required(schema.name, { message: 'Nom requis' });
// applyEach() itère sur le tableau et applique les validateurs à chaque élément
applyEach(schema.skills, (skill) => {
required(skill, { message: 'Compétence obligatoire' });
});
});
addSkill() {
// On manipule directement le signal modèle
this.model.update(m => ({
...m,
skills: [...m.skills, '']
}));
}
removeSkill(index: number) {
this.model.update(m => ({
...m,
skills: m.skills.filter((_, i) => i !== index)
}));
}
Template :
@for (skill of model().skills; track $index; let i = $index) {
<input [formField]="f.skills.at(i)" />
<button (click)="removeSkill(i)">Supprimer</button>
}
Plus besoin de FormArray, formGroupName ou de cast. L’ajout/suppression se fait directement sur le signal modèle, et applyEach() applique automatiquement la validation à chaque élément.
Async : vérification de disponibilité du pseudo
Avant — Validateur asynchrone Reactive Forms
this.form = this.fb.group({
username: ['', [Validators.required], [this.usernameValidator.bind(this)]]
});
usernameValidator(control: FormControl) {
return this.http.get(`/api/check/${control.value}`).pipe(
map(exists => exists ? { taken: true } : null),
debounceTime(300),
first()
);
}
Problèmes : le this binding, la gestion du debounce à la main, le mélange RxJS/validateur.
Après — Signal Forms
import { validateHttp } from '@angular/forms/signals';
this.f = form(this.model, (schema, checker) => {
required(schema.username, { message: 'Pseudo requis' });
validateHttp(schema.username, {
debounce: 300,
request: ({ value }) => `/api/check/${value()}`,
onSuccess: (response: { taken: boolean }) =>
response.taken
? { kind: 'taken', message: 'Pseudo déjà pris' }
: null,
onError: () => ({
kind: 'networkError',
message: 'Impossible de vérifier le pseudo',
}),
});
});
Le debounce est intégré (debounce: 300 dans les options), l’API est native et déclarative (request/onSuccess/onError), la logique est colocalisée.
Contrôle custom : l’équivalent du ControlValueAccessor
Quand on crée un composant de formulaire sur mesure (date picker, éditeur riche, toggle…), il faut le connecter au système de formulaires. Avant, c’était le ControlValueAccessor, une interface verbeuse et procédurale. Signal Forms remplace ça par des interfaces à base de signaux.
Avant — ControlValueAccessor
import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-rating',
template: `
<div class="rating">
<button *ngFor="let star of stars; let i = index"
(click)="select(i + 1)"
[class.active]="i < value">
★
</button>
</div>
`,
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingControl),
multi: true
}]
})
export class RatingControl implements ControlValueAccessor {
value = 0;
disabled = false;
stars = Array(5).fill(0);
// Fonctions enregistrées par Angular — c'est du callback spaghetti
private onChange: (value: number) => void = () => {};
private onTouched: () => void = () => {};
// Angular appelle cette méthode pour donner la valeur initiale
writeValue(value: number): void {
this.value = value;
}
// Angular enregistre sa fonction de callback
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
// Angular enregistre sa fonction de callback pour le "touched"
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
// Angular dit au contrôle s'il est désactivé
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
select(rating: number): void {
this.value = rating;
this.onChange(rating); // Il faut penser à appeler le callback manuellement
this.onTouched();
}
}
Problèmes du ControlValueAccessor :
- Callback spaghetti :
onChangeetonTouchedsont des fonctions stockées dans des variables, assignées par Angular après coup. - Boilerplate obligatoire :
NG_VALUE_ACCESSOR,forwardRef,providers: [...], 4 méthodes à implémenter. - Pas de réactivité : tout est impératif, aucune intégration native avec les signaux.
- Pas d’état formulaire : pour afficher les erreurs, il faut les passer manuellement en
@Input().
Après — FormValueControl (Signal Forms)
import { Component, model, input } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-rating',
template: `
<div class="rating" [class.disabled]="disabled()">
@for (star of stars; track $index; let i = $index) {
<button (click)="select(i + 1)"
[class.active]="i < value()"
[disabled]="disabled()">
★
</button>
}
@if (invalid()) {
<div class="error">
@for (error of errors(); track error) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
`,
// Plus besoin de NG_VALUE_ACCESSOR ni de forwardRef !
})
export class RatingControl implements FormValueControl<number> {
stars = Array(5).fill(0);
// Le model() remplace writeValue + registerOnChange
value = model(0);
// L'état du formulaire arrive automatiquement via input()
touched = model(false);
disabled = input(false);
invalid = input(false);
errors = input<readonly { message: string }[]>([]);
select(rating: number): void {
this.value.set(rating); // Un seul appel, tout est synchronisé
this.touched.set(true); // Le formulaire sait que l'utilisateur a interagi
}
}
Utilisation dans un formulaire :
<form>
<label>Votre note :
<!-- Le [formField] suffit, tout est connecté automatiquement -->
<app-rating [formField]="reviewForm.rating" />
</label>
@if (reviewForm.rating().invalid() && reviewForm.rating().touched()) {
<div>
@for (error of reviewForm.rating().errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
</form>
Ce qui change radicalement :
- Plus de
NG_VALUE_ACCESSOR: l’interfaceFormValueControlest détectée automatiquement par la directive[formField]. - Plus de callbacks : un
model()bidirectionnel remplacewriteValue/registerOnChange. - État natif :
disabled(),invalid(),errors()arrivent directement dans le composant viainput(), plus besoin de@Input()manuels. - Moins de code : ~50 lignes → ~30 lignes, aucun provider à déclarer, aucune fonction callback à stocker.
Gain en testabilité
Avant
it('should require email', () => {
const emailControl = component.form.get('email');
emailControl?.setValue('');
expect(emailControl?.valid).toBeFalse();
expect(emailControl?.errors?.['required']).toBeTruthy();
});
Après
it('should require email', () => {
component.f.email.setValue('');
expect(component.f.email().invalid()).toBeTrue();
expect(component.f.email().errors().some(e => e.kind === 'required')).toBeTrue();
});
Pas de ?. optionnel, accès direct, API synchrone et typée. Les tests sont plus concis et plus robustes.
Ce qu’il faut retenir
| Critère | Avant (Reactive Forms / ngModel) | Signal Forms (v22) |
|---|---|---|
| Typage | Faible, any/Partial, pas de vérification compile | Fort, inféré du modèle |
| Accès aux champs | form.get('name') ou #ref="ngModel" (chaînes, refs templates) | f.name (propriétés, autocomplétion) |
| Validation croisée | Sur le groupe, erreur générique | Sur le champ concerné, erreur localisée |
| Champs dynamiques | FormArray + casts, ou *ngFor + ngModel manuel | Tableau de signaux natif |
| Async | Validateur RxJS, debounce manuel, pas de gestion native pour ngModel | validateHttp / validateAsync avec debounce intégré |
| Messages d’erreur | À gérer manuellement dans le template | Embarqués dans les validateurs |
| Contrôle custom | ControlValueAccessor + NG_VALUE_ACCESSOR + callbacks | FormValueControl + model() + [formField] |
| Tests | Chaînes, optionnel chaining, change detection manuelle | Accès direct, typé, pas de fixture.detectChanges() |
| Boilerplate | Élevé (FormGroup, FormBuilder, providers CVA, directives ngModel…) | Minimal |
Signal Forms ne se contentent pas d’être une nouvelle syntaxe : ils repensent la manière de concevoir les formulaires dans Angular. Moins de code, plus de sécurité, et une expérience développeur qui monte d’un cran. Avec le passage en stable dans Angular 22, c’est le moment de les adopter.