Signal Forms est le nouveau système de formulaires d’Angular, construit sur les signals. Il est apparu en version expérimentale en v21 et est devenu une API publique stable en v22. L’idée centrale : vous partez d’un modèle (un signal contenant vos données), et form() construit par-dessus un arbre de champs avec leur état (valid, touched, errors…) et des règles définies dans un schéma.
| Reactive Forms | Signal Forms | |
|---|---|---|
| Source de vérité | FormGroup (copie interne) | votre modèle (signal) |
| Structure | construite à la main (FormControl, FormGroup) | inférée du type du modèle |
| Validation | validators par contrôle | schéma : required(p.email), validate(...) |
| Réactivité | valueChanges (Observable) | signals (value(), valid()) |
| Binding | formControlName | [formField] |
| Typage | bon (typed forms, v14+) | complet, déduit du modèle |
Le premier formulaire
import { Component, signal } from '@angular/core';
import { FormField, FormRoot, email, form, minLength, required } from '@angular/forms/signals';
interface Login {
email: string;
password: string;
}
@Component({
selector: 'app-login',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="loginForm">
<input type="email" [formField]="loginForm.email" />
@if (loginForm.email().touched() && loginForm.email().invalid()) {
@for (e of loginForm.email().errors(); track e.kind) { <small>{{ e.message }}</small> }
}
<input type="password" [formField]="loginForm.password" />
<button type="submit" [disabled]="loginForm().submitting()">Se connecter</button>
</form>
`,
})
export class LoginForm {
// 1. LE MODÈLE : un signal ordinaire — la source de vérité
readonly model = signal<Login>({ email: '', password: '' });
// 2. LE FORMULAIRE : un arbre de champs au-dessus du modèle + le SCHÉMA de règles
readonly loginForm = form(
this.model,
(path) => {
required(path.email, { message: 'L’e-mail est obligatoire' });
email(path.email, { message: 'E-mail invalide' });
required(path.password);
minLength(path.password, 8, { message: '8 caractères minimum' });
},
{ submission: { action: async (f) => this.auth.login(f().value()) } },
);
}Le modèle EST le formulaire
// Le modèle et le formulaire sont la MÊME chose — il n’y a aucune copie interne
this.model.set({ email: 'ana@atlas.dev', password: '' }); // → l’input se met à jour
this.loginForm.email().value.set('x@y.ro'); // → model() se met à jour
// Cela signifie :
// - vous chargez les données du serveur avec model.set(...)
// - vous pouvez tout dériver avec computed(() => this.model().email)
// - plus de patchValue / setValue / valueChangesL’état d’un champ
// Chaque nœud de l’arbre est une fonction : appelez-la et vous obtenez l’état (signals)
const email = this.loginForm.email(); // FieldState<string>
email.value(); // WritableSignal<string> — lié directement au modèle
email.value.set('a@b.ro');
email.valid(); email.invalid(); email.pending();
email.touched(); email.dirty();
email.errors(); // ValidationError[] — { kind, message }
email.getError('email');
email.disabled(); email.hidden(); email.readonly(); email.required();
email.markAsTouched();
email.focus(); // v21.1 : focus sur le contrôle lié
// Le formulaire entier est aussi un champ
this.loginForm().valid();
this.loginForm().submitting();
this.loginForm().errorSummary(); // toutes les erreurs de l’arbre
this.loginForm().reset();Quand afficher les erreurs
Un champ peut être invalide bien avant que l’utilisateur en ait fini : ana@ n’est pas encore un e-mail. L’état du champ vous donne tout ce qu’il faut pour choisir le moment : dirty() (la valeur a changé), touched() (l’utilisateur a quitté le champ) et valid().
dirty() && invalid() : le message apparaît dès la première frappe.
| password | ||
|---|---|---|
dirty() | false | false |
touched() | false | false |
valid() | false | false |
| erreur à l’écran | false | false |
- Frappes
- 0
- Frappes avec une erreur affichée
- 0
Afficher les erreurs pendant la frappe (dirty), c’est reprocher à l’utilisateur un mot qu’il n’a pas fini d’écrire. Attendre touched est le choix habituel : le premier verdict tombe quand il quitte le champ, puis le message se met à jour en direct et disparaît dès que la valeur est correcte. Attendre l’envoi convient aux formulaires courts ; une tentative d’envoi marque tous les champs comme touchés. Quel que soit votre choix, mettez-le à un seul endroit — un composant comme <app-field-errors> — et pas dans chaque template.
Validators
import { email, max, maxLength, min, minLength, pattern, required, validate } from '@angular/forms/signals';
form(this.model, (p) => {
required(p.name, { message: 'Obligatoire' });
minLength(p.name, 2);
maxLength(p.bio, 280);
email(p.email);
min(p.age, 18, { message: '18 ans minimum' });
max(p.age, 120);
pattern(p.zip, /^\d{6}$/, { message: 'Code postal à 6 chiffres' });
required(p.terms); // sur un booléen : false = invalide (comme une case à cocher native)
// conditionnel
required(p.company, { when: ({ valueOf }) => valueOf(p.type) === 'business' });
// personnalisé, entre des champs
validate(p.confirm, ({ value, valueOf }) =>
value() !== valueOf(p.password) ? { kind: 'mismatch', message: 'Les mots de passe ne correspondent pas' } : undefined,
);
});Soumettre le formulaire
// Option 1 : submission dans les options de form() + <form [formRoot]>
readonly f = form(this.model, schemaFn, {
submission: {
action: async (field) => {
try {
await this.api.register(field().value());
return undefined; // succès
} catch {
// erreurs serveur liées à un champ précis
return { kind: 'taken', message: 'E-mail déjà utilisé', fieldTree: field.email };
}
},
onInvalid: (field) => field().errorSummary()[0]?.fieldTree().focus(), // focus sur la première erreur
},
});
// Option 2 : un submit() manuel
async save() {
await submit(this.f, async (field) => this.api.save(field().value()));
}Ce que vous pouvez lier avec [formField]
<!-- [formField] fonctionne sur tous les contrôles natifs : -->
<input [formField]="f.name" /> <!-- string -->
<input type="number" [formField]="f.age" /> <!-- number | null -->
<input type="checkbox" [formField]="f.terms" /> <!-- boolean -->
<input type="date" [formField]="f.birthday" /> <!-- string / Date -->
<select [formField]="f.country">...</select>
<textarea [formField]="f.bio"></textarea>
<input type="radio" value="a" [formField]="f.plan" />
<!-- et sur les contrôles personnalisés : FormValueControl / FormCheckboxControl / ControlValueAccessor -->
<app-rating-control [formField]="f.rating" />Champs conditionnels, listes dynamiques et validation asynchrone : Signal Forms — avancé ; vos propres contrôles : Contrôles de formulaire personnalisés.
Quand OUI, quand NON
- Tout nouveau formulaire dans une application v22.
- Les formulaires avec beaucoup de logique conditionnelle (champs masqués, champs désactivés, validations qui dépendent d’autres champs).
- Quand vous voulez que les données soient un simple signal, facile à dériver et à enregistrer.
- Une grande application avec des centaines de reactive forms qui fonctionnent — migrez progressivement, pas tout d’un coup.
- Les bibliothèques de composants qui ne prennent en charge que
ControlValueAccessor+ Reactive Forms (vérifiez l’interopérabilité CVA). - L’équipe a besoin de
valueChangescomme Observable partout (même sitoObservablerègle le problème).
Pièges fréquents
- Vous oubliez d’appeler le champ :
f.email.valid()n’existe pas ; la bonne forme estf.email().valid()(d’abord l’état, puis le signal). - Erreurs affichées immédiatement : ne les affichez qu’après
touched()(ou après une soumission). La soumission marque automatiquement tout comme touched. - Muter le modèle :
model().email = 'x'ne notifie rien. Utilisezmodel.update()ouf.email().value.set(). min/maxavec des chaînes : depuis la v22, ils n’acceptent que des nombres (breaking change).