~10 min de lecture
Signal Forms est stable : migre ton premier Reactive Form (et les 5 pièges qui t'attendent)
TL;DR
Depuis Angular 22, Signal Forms est dans l'API publique : le modèle de ton formulaire est un signal, la validation se déclare dans un schéma, [formField] câble les inputs, et submit() gère le cycle de soumission (touched, submitting, erreurs). Tout l'état (valid, errors, dirty, touched, submitting) est exposé en signals, donc plus aucun pont valueChanges + toSignal() à entretenir. La migration d'un Reactive Form est mécanique, mais cinq pièges t'attendent : le modèle initialisé à null (et le number qui le devient), le champ que tu oublies d'appeler comme une fonction, les attributs posés en double sur un élément [formField], l'await oublié dans l'action de submit(), et la validation de saisie prise pour une validation de la frontière réseau.
Les comportements décrits ici ont été mesurés sur Angular 22.1.6, au compilateur et à l'exécution, en zoneless avec Vitest. Côté versions : Signal Forms est expérimental en 21.x, où la 21.1 a renommé [field] en [formField]. Il passe dans l'API publique en v22. Si tu es en dessous, cet article te dit ce qui t'attend, pas ce que tu peux déployer.
Le problème : ton formulaire vit dans un monde sans signals
Ton application est passée aux signals : composants OnPush, état en signal() et computed(), peut-être déjà zoneless. Et au milieu, tes formulaires parlent encore le dialecte de 2016 : des Observables.
Regarde un formulaire d'inscription banal :
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { score } from './password-strength';
@Component({
selector: 'app-signup',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="signupForm" (ngSubmit)="onSubmit()">
<input formControlName="email" />
<input type="password" formControlName="password" />
<input type="number" formControlName="age" />
<p>Force du mot de passe : {{ passwordStrength() }}</p>
<button type="submit" [disabled]="signupForm.invalid">Créer le compte</button>
</form>
`,
})
export class Signup {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly signupForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(12)]],
age: [0, [Validators.min(18)]],
});
protected readonly passwordStrength = toSignal(
this.signupForm.controls.password.valueChanges.pipe(map((p) => score(p))),
{ initialValue: 'faible' },
);
protected onSubmit(): void {
if (this.signupForm.invalid) {
this.signupForm.markAllAsTouched();
return;
}
// ... appel API, gestion du loading à la main
}
}
Compte les frictions. Un builder injecté juste pour éviter les null (et tu sais pourquoi si tu as lu les pièges des typed forms). Un toSignal() pour ramener la valeur d'un contrôle dans le monde des signals, avec son initialValue parce que valueChanges n'émet pas la valeur courante. Un markAllAsTouched() manuel pour révéler les erreurs à la soumission. Et un champ désactivé sous condition ? Un effect() qui appelle disable() et enable() au bon moment. Chaque besoin réactif te coûte un pont.
Signal Forms supprime les ponts : le formulaire est construit en signals de bout en bout.
La solution : le modèle est un signal, tout le reste en découle
Le renversement conceptuel tient en une phrase : tu ne déclares plus des contrôles qui contiennent des valeurs, tu déclares une valeur (un signal) sur laquelle le formulaire se branche.
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { email, form, FormField, min, minLength, required, submit } from '@angular/forms/signals';
import { score } from './password-strength';
import { SignupGateway } from './signup.gateway';
@Component({
selector: 'app-signup',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormField],
template: `
<form (submit)="onSubmit(); $event.preventDefault()">
<input [formField]="signupForm.email" />
<input type="password" [formField]="signupForm.password" />
<input type="number" [formField]="signupForm.age" />
<p>Force du mot de passe : {{ passwordStrength() }}</p>
<button type="submit" [disabled]="signupForm().invalid() || signupForm().submitting()">
Créer le compte
</button>
</form>
`,
})
export class Signup {
private readonly api = inject(SignupGateway);
protected readonly model = signal({ email: '', password: '', age: 0 });
protected readonly signupForm = form(this.model, (path) => {
required(path.email, { message: 'Email requis' });
email(path.email);
required(path.password);
minLength(path.password, 12, { message: '12 caractères minimum' });
min(path.age, 18);
});
protected readonly passwordStrength = computed(() => score(this.model().password));
protected async onSubmit(): Promise<void> {
await submit(this.signupForm, async () => {
await this.api.create(this.model());
return undefined;
});
}
}
Ce qui a disparu : le FormBuilder, les Validators, le toSignal(), le markAllAsTouched(), la gestion manuelle du loading. Ce qui les remplace :
- Le modèle est un
WritableSignalque tu possèdes.form()ne copie pas la valeur : le signal reste la source de vérité, en lecture comme en écriture. Écristhis.model.set(...)et le DOM suit ; tape dans l'input etthis.model()change. LepasswordStrengthdevient uncomputed()ordinaire sur le modèle, sans interop. - La validation est un schéma déclaratif.
required(),email(),minLength(),min()s'accrochent à des chemins typés (path.email), pas à des contrôles. Les erreurs sortent en signals :signupForm.email().errors()retourne un tableau d'objets avec unkind('required','email'...) et tonmessage. [formField]câble l'élément natif. La directive pilote la valeur, ledisabled, lereadonly. Bonus vérifié : sur un<input type="number">, la valeur arrive dans le modèle ennumber, pas enstring- ce parsing natif est là depuis les débuts de Signal Forms ; la 21.2 y a ajouté l'erreur de parsing et lenullsur champ vidé (avant :NaN). Ce champ vidé est le cas à part du piège 1.submit()orchestre la soumission. Formulaire invalide : l'action n'est pas appelée et tous les champs sont marquéstouched(), ce qui révèle les erreurs. Formulaire valide : l'action tourne, etsignupForm().submitting()est vrai tant que ta Promise n'est pas résolue. Ton spinner de bouton est déjà écrit.
La directive [formRoot] peut faire ce travail à ta place - elle pose novalidate sur le <form>, intercepte le submit natif et déclenche l'action de soumission déclarée dans form() (son option submission). L'exemple garde le (submit) manuel pour laisser chaque fil visible.
Un mot sur la lecture d'état, parce que c'est la syntaxe à intégrer : un champ se lit en l'appelant comme une fonction, puis en lisant le signal d'état voulu.
signupForm().invalid() // état de la racine
signupForm.email().errors() // erreurs du champ email
signupForm.email().value() // valeur du champ - .value est le WritableSignal, l'appel le lit
signupForm.email().touched() // touched, posé au blur
signupForm.email sans appel est le nœud de champ : ce que form() crée pour chaque propriété du modèle - un nœud lui-même appelable, tu viens de le voir - et ce que tu passes à [formField]. Son type s'appelle FieldTree, parce que ces nœuds s'imbriquent exactement comme le modèle. signupForm.email() est son état. Les deux existent, et c'est le piège 2 ci-dessous.
Les 5 pièges
1. Le modèle initialisé à null (et le number qui le devient)
Le réflexe Reactive Forms de laisser des champs à null se paie immédiatement sur les strings. Vérifié au compilateur : avec un modèle { email: string | null }, required(path.email) passe encore, mais email(path.email) et minLength(path.email, 3) refusent de compiler - email() exige un champ string, minLength() une valeur qui a une longueur. L'erreur tombe sur la ligne du validateur, pas sur celle du modèle, mais le message nomme le coupable : Type 'null' is not assignable to type 'string' pour email() ; pour minLength(), la même fin de message vise ValueWithLengthOrSize, le type des valeurs qui ont une length ou une size - string, tableau, Set, Map.
La règle pour les strings et les tableaux : des défauts non nuls, '' et []. C'est ce que NonNullableFormBuilder t'avait appris à faire ; la différence, c'est qu'ici le compilateur t'arrête dès le schéma.
Les numbers sont un cas à part, mesuré : vide un <input type="number"> et le modèle reçoit null, même quand ton type dit number. Le type ment. Un champ numérique que l'utilisateur peut vider se type donc number | null - c'est légitime, min() l'accepte. Et un défaut 0 sous un min(path.age, 18) rend le formulaire invalide dès le premier rendu : voulu pour un champ obligatoire, gênant pour un optionnel. L'exemple d'inscription plus haut assume le premier cas : age y est obligatoire, et son défaut 0 le laisse en erreur min dès le premier rendu - comme email et password, vides eux aussi. Le bouton ne s'active qu'une fois les trois champs valides, age compris, c'est-à-dire à 18 ou plus.
2. Le champ que tu oublies d'appeler
signupForm.email().errors() // OK
signupForm.email.errors() // erreur de compilation : errors n'existe pas sur FieldTree
signupForm.invalid() // erreur de compilation : il manque l'appel de la racine
La bonne nouvelle : c'est une erreur de compilation, pas un bug silencieux. La mauvaise : tu vas l'écrire dix fois la première semaine, parce que dix ans de form.invalid et de control.errors ont câblé ta mémoire musculaire. Mnémotechnique : d'abord tu ouvres le champ (), ensuite tu lis le signal (). Deux paires de parenthèses, toujours.
3. Les attributs posés en double sur un élément [formField]
<!-- NG8022 : Binding to '[disabled]' is not allowed on nodes using the '[formField]' directive -->
<input type="number" [formField]="signupForm.age" [disabled]="busy()" />
<!-- NG8022 : Setting the 'min' attribute is not allowed on nodes using the '[formField]' directive -->
<input type="number" [formField]="signupForm.age" min="18" />
<!-- tout passe par le schéma -->
<input type="number" [formField]="signupForm.age" />
[formField] possède l'élément : la valeur (value, checked), l'état (disabled, readonly, hidden, required, touched, dirty, invalid, pending, errors), les contraintes (min, max, minlength, maxlength, pattern) et name. Et ce n'est pas une discipline à t'imposer, c'est le compilateur qui l'impose. Vérifié au build : un binding posé en parallèle ([disabled]="busy()", [min]="borne()", [value], et même [attr.min]) comme un attribut statique (min="18", required, disabled) font échouer la compilation avec l'erreur NG8022 et l'une des deux formes de message du bloc ci-dessus - strictTemplates activé ou pas, élément natif ou contrôle custom. Ton réflexe Reactive Forms de poser un [disabled] à côté du binding de contrôle ne produit pas un bug subtil : il ne build pas. Ce que la directive ne pilote pas reste libre - placeholder, step, id, class, les aria-*, les écouteurs d'événements. En revanche name, maxlength et pattern sont dans sa liste : eux aussi refusés, et ce sont ceux qu'une migration depuis Reactive Forms laisse traîner.
La parade est celle que le compilateur t'indique : les contraintes se déclarent dans le schéma (min(path.age, 18), disabled(path, { when })), jamais sur le DOM. Vérifié à l'exécution : la règle disabled() du schéma pose et retire l'attribut disabled toute seule quand sa condition when bascule. Ton effect() qui appelait enable() et disable() part à la poubelle.
Corollaire pour le bouton submit : il n'est pas porté par [formField], donc son [disabled]="signupForm().invalid() || signupForm().submitting()" est légitime.
4. submit() veut une action qui attend vraiment
La signature n'est pas là pour décorer : l'action passée à submit() retourne une Promise (de TreeValidationResult, c'est-à-dire undefined ou null quand tout va bien, ou des erreurs à poser sur les champs). C'est cette Promise qui alimente submitting() et qui porte les erreurs serveur jusqu'aux champs.
La version purement synchrone ne compile pas (TS2769, Type 'void' is not assignable to type 'Promise<TreeValidationResult<WithOptionalFieldTree>>'). Le piège qui compile, c'est l'await oublié :
// compile, mais submitting() retombe avant la fin de l'appel HTTP
submit(this.signupForm, async () => {
this.api.create(this.model()); // il manque l'await
return undefined;
});
// submitting() est vrai pendant tout l'appel, les erreurs retournées rejoignent le form
await submit(this.signupForm, async () => {
await this.api.create(this.model());
return undefined;
});
Mesuré : avec l'await oublié, submitting() retombe alors que le POST est encore en vol. Spinner éteint et bouton réactivé trop tôt.
Si ton POST échoue avec une erreur métier, retourne-la depuis l'action au lieu de bricoler un signal d'erreur à côté du formulaire : elle rejoint le même canal errors() que la validation locale, et ton template n'a qu'un seul endroit à lire.
5. Valider la saisie n'est pas valider la frontière réseau
required(), email(), pattern() gardent l'entrée utilisateur. Ils ne valident pas ce qui revient du serveur, ni ce que tu relis depuis un stockage local. Un formulaire valide qui envoie un POST peut parfaitement recevoir une réponse malformée, et aucun schéma Signal Forms ne te protégera de ça : la frontière réseau se valide au runtime (Zod par exemple), comme avant.
Deux barrières, deux responsabilités. Si ton type de modèle vient déjà d'un schéma Zod, garde ce schéma comme source du type, et reporte les contraintes de saisie dans le schéma Signal Forms. Oui, c'est une duplication partielle des règles. Elle est assumée : les deux ne valident pas la même chose au même moment.
Tes tests de formulaire perdent leurs échafaudages
Le gain est net ici aussi. Plus besoin de setValue() sur des contrôles : tu pilotes le signal du modèle et tu assertes des signals d'état. Seul prérequis : sortir les règles de validation de form() dans une constante partagée, construite avec le helper schema (importé lui aussi de @angular/forms/signals) : const signupSchema = schema<SignupModel>((path) => { ... }), où SignupModel est le type du modèle. Sans ce schema<T>(), la lambda extraite perd son typage contextuel et le compilateur la refuse (TS7006). Le composant et le test passent ensuite la même constante à form().
it('refuse un mot de passe trop court puis accepte la correction', () => {
TestBed.runInInjectionContext(() => {
const model = signal({ email: 'a@b.co', password: 'short', age: 20 });
const f = form(model, signupSchema);
expect(f.password().errors()[0].kind).toBe('minLength');
model.update((m) => ({ ...m, password: 'long-enough-pass' }));
expect(f().valid()).toBe(true);
});
});
Deux comportements vérifiés à connaître : touched() se pose au blur, pas à la saisie (pendant la frappe, c'est dirty()) ; et submit() marque tous les champs touched() même quand l'action n'est pas appelée. Côté soumission, teste les deux faces : form invalide, le spy de ta gateway n'est pas appelé ; form valide, il est appelé une fois avec le modèle attendu.
Récap actionnable
- Formulaire neuf en v22+ : Signal Forms. Reactive Forms reste légitime sur l'existant pas encore migré, mais pas au prétexte d'une lib tierce : ses contrôles s'intègrent aux formulaires Angular en implémentant
ControlValueAccessor, et[formField]sait piloter un tel contrôle tel quel (mesuré). Pour un contrôle custom que tu écris côté Signal Forms, le contrat s'appelleFormValueControl(exporté par@angular/forms/signals, commeFormCheckboxControlpour les cases à cocher). - Migre formulaire par formulaire, pas à moitié. Un formulaire qui mélange durablement les deux mondes cumule les modèles mentaux. L'exception, sur une grosse base : le pont
SignalFormControl(exposé via@angular/forms/signals/compat, présenté à sa sortie en 21.2) convertit contrôle par contrôle - un état de transit daté, pas l'état d'arrivée. - Un formulaire migré, deux écrans à servir. Le découpage création / édition a sa propre réponse : un composant de forme, deux conteneurs.
- Défauts non nuls pour les strings et les tableaux (
'',[]) ; un champ numérique que l'utilisateur peut vider se typenumber | null, et un défaut0sous unmin()à borne strictement positive rend le formulaire invalide dès le premier rendu. - Deux paires de parenthèses pour lire l'état :
signupForm.email().errors(). - Aucun attribut piloté par le schéma posé à la main sur un élément
[formField]: ni binding parallèle ([disabled],[min]), ni attribut statique (min="18") - le compilateur les refuse (NG8022). Contraintes etdisabledvivent dans le schéma. awaitdans l'action desubmit(): c'est lui qui tientsubmitting(). Erreurs serveur retournées par l'action, bouton désactivé surinvalid() || submitting().- Le schéma du formulaire valide la saisie, pas la frontière réseau. Ta validation runtime des réponses HTTP reste en place.
Le plus dur dans cette migration n'est pas l'API, c'est de désapprendre les réflexes : arrêter de chercher le contrôle, et penser modèle d'abord. Prends le formulaire le plus simple de ton app, migre-le en entier, et compte les lignes qui disparaissent. Le déclic viendra avant la fin du diff.