Découvrez Signal Forms d'Angular 21 : API signal-based, validation déclarative, schémas réutilisables et migration depuis Reactive Forms avec exemples.
Pourquoi Signal Forms ?
Les Reactive Forms d'Angular existent depuis 2016 et restent solides. Pourquoi alors une nouvelle API ? La réponse tient en trois mots : signaux, typage et déclaratif. Reactive Forms repose sur RxJS (valueChanges, statusChanges) et impose des subscriptions manuelles, des FormBuilder verbeux, et un typage limité (le passage à FormGroup<T> en Angular 14 a aidé mais reste imparfait).
Signal Forms, introduit en developer preview avec Angular 19 et stabilisé en Angular 21, propose :
- Lecture synchrone via Signal :
form.value(),form.valid(),form.dirty()sansasyncpipe nisubscribe. - Typage end-to-end : le schéma déclaratif infère les types automatiquement, du modèle au template.
- Validation déclarative : les règles sont des fonctions pures lisant des Signals — réévaluation automatique.
- Schémas réutilisables : un même schéma peut alimenter plusieurs formulaires (création/édition).
- Compatible zoneless : aucune dépendance à Zone.js dans le pipeline de validation.
@angular/forms/signals) et stable en Angular 21+. Avant la v21, attendez-vous à des changements d'API mineurs entre versions.
Installation et premier formulaire
Signal Forms fait partie du package @angular/forms à partir d'Angular 21. Pas de dépendance supplémentaire à installer. Pour activer l'API, on importe les helpers depuis @angular/forms/signals.
// app.config.ts — provider standard
import { ApplicationConfig } from '@angular/core';
import { provideForms } from '@angular/forms/signals';
export const appConfig: ApplicationConfig = {
providers: [
// Active les directives Signal Forms globalement
provideForms()
]
};
Premier formulaire — modèle utilisateur :
// user-form.component.ts
import { Component, signal } from '@angular/core';
import { form, validate, required, minLength, email } from '@angular/forms/signals';
interface UserModel {
firstName: string;
lastName: string;
email: string;
age: number;
}
@Component({
selector: 'app-user-form',
standalone: true,
template: `
<form (ngSubmit)="submit()">
<label>
Prénom
<input [control]="userForm.firstName" />
@if (userForm.firstName.errors().length) {
<span class="error">{{ userForm.firstName.errors()[0].message }}</span>
}
</label>
<label>
Email
<input type="email" [control]="userForm.email" />
</label>
<button type="submit" [disabled]="!userForm.valid()">Envoyer</button>
</form>
`
})
export class UserFormComponent {
// Modèle initial — Signal modifiable
private model = signal<UserModel>({
firstName: '',
lastName: '',
email: '',
age: 0
});
// Création du formulaire avec schéma de validation
readonly userForm = form(this.model, (path) => {
// Règles déclaratives champ par champ
validate(path.firstName, required(), minLength(2));
validate(path.lastName, required());
validate(path.email, required(), email());
validate(path.age, (v) => v >= 18 ? null : { code: 'minAge', message: 'Vous devez avoir 18 ans' });
});
submit() {
if (this.userForm.valid()) {
// value() retourne le modèle complet typé
console.log('Données soumises :', this.userForm.value());
}
}
}
Les points clés :
- Le modèle de départ est un simple
signal(). Pas besoin deFormBuilderni d'instancier des contrôles à la main. form(model, schema)retourne un objet typé qui mappe chaque clé du modèle à un contrôle exposantvalue(),errors(),dirty(),touched(),valid().- La directive
[control]="userForm.firstName"remplace l'ancienneformControlName.
FormBuilder.group({...}) imbrique les contrôles dans un objet runtime, Signal Forms construit un proxy typé statiquement à partir du modèle. Le coût mémoire est inférieur car aucun FormControl n'est instancié pour chaque champ.
Validation déclarative et schémas
La validation Signal Forms est fonctionnelle : chaque validateur est une fonction pure qui reçoit la valeur courante et retourne null (valide) ou un objet d'erreur. Pas de classe, pas de provider, pas de boilerplate.
Validators built-in disponibles :
| Validator | Usage | Code d'erreur |
|---|---|---|
required() | Champ obligatoire | required |
minLength(n) | Longueur min string/array | minLength |
maxLength(n) | Longueur max | maxLength |
min(n) | Valeur min numérique | min |
max(n) | Valeur max numérique | max |
pattern(regex) | Regex match | pattern |
email() | Format email RFC | email |
Validation conditionnelle (cross-field) :
interface PasswordModel {
password: string;
confirm: string;
}
readonly passwordForm = form(this.model, (path) => {
validate(path.password, required(), minLength(8));
validate(path.confirm, required());
// Validation cross-field : on lit deux champs
// Le validateur reçoit le formulaire complet en deuxième argument
validate(path.confirm, (confirm, ctx) => {
// ctx.root() expose le modèle complet en lecture
const expected = ctx.root().password;
return confirm === expected
? null
: { code: 'mismatch', message: 'Les mots de passe ne correspondent pas' };
});
});
Schéma réutilisable — extraction dans un fichier dédié :
// schemas/user.schema.ts
import { Schema, validate, required, email, minLength } from '@angular/forms/signals';
import { UserModel } from '../models/user.model';
export const userSchema: Schema<UserModel> = (path) => {
validate(path.firstName, required(), minLength(2));
validate(path.lastName, required(), minLength(2));
validate(path.email, required(), email());
validate(path.age, (v) => v >= 0 ? null : { code: 'positive', message: 'Âge positif requis' });
};
// Réutilisation dans plusieurs composants
// create-user.component.ts
this.form = form(this.emptyModel, userSchema);
// edit-user.component.ts
this.form = form(this.existingUserModel, userSchema);
userSchema avec Vitest sans TestBed.
Validation conditionnelle avec champ activable :
interface ProfileModel {
notificationEnabled: boolean;
email: string;
}
readonly profileForm = form(this.model, (path) => {
// L'email n'est requis que si les notifications sont activées
validate(path.email, (value, ctx) => {
const enabled = ctx.root().notificationEnabled;
if (!enabled) return null; // skip validation
if (!value) return { code: 'required', message: 'Email obligatoire si notifications activées' };
return null;
});
});
Validation asynchrone avec resource()
Pour les validations qui nécessitent un appel serveur (vérifier qu'un username est disponible, par exemple), Signal Forms s'intègre avec resource() et httpResource() d'Angular 19+.
import { resource } from '@angular/core';
import { form, validateAsync } from '@angular/forms/signals';
@Component({ /* ... */ })
export class SignupComponent {
private http = inject(HttpClient);
private model = signal({ username: '', email: '' });
readonly form = form(this.model, (path) => {
// Validation synchrone d'abord
validate(path.username, required(), minLength(3));
// Puis validation asynchrone côté serveur
validateAsync(path.username, async (value) => {
// Annulation automatique si l'utilisateur retape entre-temps
const available = await firstValueFrom(
this.http.get<{ available: boolean }>(`/api/check-username?u=${value}`)
);
return available.available
? null
: { code: 'taken', message: 'Username déjà pris' };
}, { debounce: 300 });
});
// pending() expose l'état de chargement async
isCheckingUsername = computed(() => this.form.username.pending());
}
Points importants :
validateAsyncaccepte une optiondebouncepour limiter les appels HTTP.- Les requêtes précédentes sont automatiquement annulées (
AbortControllersous le capot). form.username.pending()permet d'afficher un spinner pendant la vérification.- L'état
valid()restefalsetant que la validation async n'est pas terminée.
touched et que pending() est false. Sinon vous fatiguez l'utilisateur avec des erreurs intermittentes.
Formulaires imbriqués et FormArray
Signal Forms gère naturellement les structures imbriquées et les listes dynamiques sans recourir à un FormArray spécifique.
Objet imbriqué :
interface ContactModel {
name: string;
address: {
street: string;
city: string;
zip: string;
};
}
readonly contactForm = form(this.model, (path) => {
validate(path.name, required());
// Accès direct aux propriétés imbriquées via le path proxy
validate(path.address.street, required());
validate(path.address.city, required());
validate(path.address.zip, pattern(/^\d{5}$/));
});
// Template
// <input [control]="contactForm.address.street" />
Tableau dynamique (équivalent FormArray) :
interface InvoiceModel {
customer: string;
items: Array<{ label: string; quantity: number; price: number }>;
}
private model = signal<InvoiceModel>({
customer: '',
items: [{ label: '', quantity: 1, price: 0 }]
});
readonly invoiceForm = form(this.model, (path) => {
validate(path.customer, required());
// applyEach applique le schéma à chaque élément du tableau
applyEach(path.items, (item) => {
validate(item.label, required());
validate(item.quantity, min(1));
validate(item.price, min(0));
});
});
addItem() {
// Mutation immutable du Signal
this.model.update(m => ({
...m,
items: [...m.items, { label: '', quantity: 1, price: 0 }]
}));
}
removeItem(index: number) {
this.model.update(m => ({
...m,
items: m.items.filter((_, i) => i !== index)
}));
}
Template avec @for :
@for (item of invoiceForm.items.controls(); track item.id; let i = $index) {
<div class="invoice-row">
<input [control]="item.label" placeholder="Article" />
<input [control]="item.quantity" type="number" />
<input [control]="item.price" type="number" />
<button type="button" (click)="removeItem(i)">Retirer</button>
</div>
}
<button type="button" (click)="addItem()">Ajouter une ligne</button>
this.itemsFA.push(this.fb.group({...})). Avec Signal Forms, on mute le modèle source et le formulaire reflète automatiquement la nouvelle structure.
Migrer depuis Reactive Forms
La migration n'est pas urgente — Reactive Forms reste pleinement supporté. Mais si vous démarrez un nouveau formulaire complexe, Signal Forms réduit significativement le code. Voici les étapes pour migrer un formulaire existant.
Avant — Reactive Forms typé :
// Reactive Forms (Angular 14+)
this.form = this.fb.nonNullable.group({
title: ['', [Validators.required, Validators.maxLength(100)]],
body: ['', Validators.required],
tags: this.fb.array<string>([])
});
// Souscription manuelle pour réagir aux changements
this.form.controls.title.valueChanges.pipe(
distinctUntilChanged(),
takeUntilDestroyed()
).subscribe(t => this.titleSlug.set(slugify(t)));
// Soumission
submit() {
if (this.form.valid) {
const value = this.form.getRawValue();
this.api.save(value);
}
}
Après — Signal Forms :
// Signal Forms (Angular 21+)
private model = signal({
title: '',
body: '',
tags: [] as string[]
});
readonly form = form(this.model, (path) => {
validate(path.title, required(), maxLength(100));
validate(path.body, required());
});
// Réaction aux changements via computed (pas de subscribe)
readonly titleSlug = computed(() => slugify(this.form.title.value()));
submit() {
if (this.form.valid()) {
// value() est typé automatiquement
this.api.save(this.form.value());
}
}
- Définir le modèle TypeScript explicite (interface)
- Convertir
FormBuilder.groupensignal()+form() - Remplacer
Validators.Xpar les helpersrequired(), minLength(), email() - Convertir
valueChanges.subscribeencomputed()oueffect() - Remplacer
formControlNamepar[control]dans les templates - Supprimer
takeUntilDestroyed(plus nécessaire avec Signals)
Tester un Signal Form
Tester un Signal Form est plus simple que tester un Reactive Form — on peut souvent se passer du TestBed et tester le schéma comme une fonction pure.
// user-form.spec.ts (Vitest)
import { describe, it, expect } from 'vitest';
import { signal } from '@angular/core';
import { form } from '@angular/forms/signals';
import { userSchema } from './schemas/user.schema';
describe('userSchema', () => {
it('rejette un email invalide', () => {
const model = signal({ firstName: 'Jean', lastName: 'Dupont', email: 'pas-un-email', age: 30 });
const f = form(model, userSchema);
expect(f.valid()).toBe(false);
expect(f.email.errors()[0].code).toBe('email');
});
it('accepte un modèle valide', () => {
const model = signal({ firstName: 'Jean', lastName: 'Dupont', email: 'jean@test.com', age: 30 });
const f = form(model, userSchema);
expect(f.valid()).toBe(true);
expect(f.email.errors()).toHaveLength(0);
});
it('met à jour la validation quand le modèle change', () => {
const model = signal({ firstName: '', lastName: '', email: '', age: 0 });
const f = form(model, userSchema);
expect(f.valid()).toBe(false);
// Mise à jour du modèle — validation réévaluée automatiquement
model.set({ firstName: 'A', lastName: 'B', email: 'a@b.fr', age: 25 });
expect(f.firstName.errors()[0].code).toBe('minLength');
});
});
Pour les tests d'intégration avec template, le TestBed reste nécessaire — mais avec moins de boilerplate qu'auparavant car il n'y a plus besoin d'importer ReactiveFormsModule.
Bonnes pratiques en production
1. Garder le modèle plat tant que possible
Plus le modèle est plat, plus le path proxy est simple à lire. Imbriquez seulement quand le domaine l'impose (adresse, contact).
2. Externaliser les schémas complexes
Un schéma de 30 lignes dans un composant rend la lecture pénible. Extrayez dans schemas/*.schema.ts et exportez une Schema<Model>.
3. Préférer computed à effect
Pour les valeurs dérivées (slug, total HT, prévisualisation), utilisez computed. effect doit être réservé aux véritables effets de bord (logger, analytics, persistance).
4. Afficher les erreurs au bon moment
// Composant utilitaire pour afficher les erreurs
@if (control.touched() && control.errors().length) {
<span class="error">{{ control.errors()[0].message }}</span>
}
Ne jamais afficher d'erreur tant que l'utilisateur n'a pas interagi avec le champ (touched) — sinon le formulaire est rouge dès le premier rendu.
5. Standardiser les codes d'erreur
Définissez un dictionnaire central de codes d'erreur côté front + i18n. { code: 'username.taken' } traduit en "Ce nom d'utilisateur est déjà pris" via $localize ou ngx-translate.
6. Mesurer la performance
Les Signal Forms réduisent typiquement la charge de change detection sur les gros formulaires (50+ champs). Mesurez avec Angular DevTools Profiler avant/après migration sur les formulaires critiques (commande, profil).
Conclusion : faut-il migrer dès maintenant ?
Signal Forms représente la réécriture la plus profonde de la gestion de formulaires depuis Angular 2. L'API signal-based remplace les FormControl/FormGroup par des structures réactives natives, alignées sur le reste du framework moderne (Signals, Standalone, control flow @if/@for). En Angular 21+, c'est l'API recommandée pour tout nouveau formulaire.
À retenir avant d'adopter Signal Forms :
- Démarrer sur du neuf : créer les nouveaux formulaires en Signal Forms, garder les Reactive Forms existants jusqu'à refactor planifié.
- Schémas réutilisables : factoriser les validations métier dans des fonctions
schema()partagées entre composants. - Validation async =
resource(): tirer parti de l'intégration native pour vérifier email/username sansdebounceTimemanuel. - Tests simplifiés : un Signal Form se teste comme un objet — plus besoin de
FormBuilderni defakeAsync. - Migration progressive : composant par composant, sans big-bang ni gel des features.
resource() pour les validations serveur, et la migration RxJS → Signals pour aligner toute votre couche réactive.