Formulaires
Template-driven, reactive, FormArray, validateurs personnalisés, Signal Forms.
Vérifié en septembre 2026 · Angular 22.1 · environ 15 min
Un formulaire tient trois états à la fois : la valeur saisie, sa validité, et l'histoire de l'interaction — champ visité, modifié, en cours de vérification. Angular 22.1 offre trois API pour les tenir, qui diffèrent d'abord par l'endroit où vit la valeur : les formulaires template-driven la gardent dans des propriétés du composant, que les directives du template recopient dans les contrôles qu'elles créent ; les formulaires réactifs la placent dans un arbre de contrôles que la classe construit ; Signal Forms, dans un signal que le composant possède. Les liaisons et model() du cours Bases, et les signals du cours Signals, sont supposés connus.
Trois API, un critère
Un formulaire template-driven s'écrit presque entièrement dans le template. Avec FormsModule importé, NgForm se pose seul sur chaque balise <form> ; chaque ngModel crée un FormControl et l'inscrit auprès de lui sous son attribut name. Les règles sont des attributs, required, email, minlength, qui sont autant de directives de validation.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-contact',
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- NgForm se pose seul sur la balise form ; #formulaire l'expose au template. -->
<form #formulaire="ngForm" (ngSubmit)="envoyer()">
<!-- Dans un form, name est obligatoire : c'est la cle du controle que ngModel
cree et inscrit aupres de NgForm. Sans lui, erreur NG01352. -->
<input name="nom" [(ngModel)]="nom" required />
<!-- Les validateurs sont des directives : required, email, minlength... -->
<input name="courriel" type="email" [(ngModel)]="courriel" #champ="ngModel" required email />
@if (champ.touched && champ.hasError('email')) {
<p>Adresse mal formee.</p>
}
<button type="submit" [disabled]="formulaire.invalid">Envoyer</button>
</form>
`,
})
export class Contact {
// [(ngModel)] accepte un signal ecrivable : la saisie y est ecrite par set.
protected readonly nom = signal('');
protected readonly courriel = signal('');
protected envoyer(): void {
console.log({ nom: this.nom(), courriel: this.courriel() });
}
} Cette simplicité a un prix. Les contrôles naissent des directives, et de façon asynchrone : dans ngAfterViewInit, formulaire.controls est encore vide, et ne se remplit qu'à la microtâche suivante. La structure et les règles n'existent que dans le template : elles ne se testent pas sans lui, et formulaire.value est typé any. Le critère de choix n'est donc pas la taille du formulaire, mais l'endroit où l'on veut sa vérité et sa structure.
| API | Source de vérité | Structure décidée par | Typage | Quand la choisir |
|---|---|---|---|---|
| Template-driven | les propriétés du composant, que ngModel recopie | le template | aucun sur le formulaire | formulaire court et fixe : connexion, recherche, contact |
| Réactifs | l'arbre FormGroup / FormControl | la classe, de façon synchrone | inféré des valeurs initiales | structure dynamique, règles croisées, code existant, observables |
| Signal Forms | un signal du composant | la forme du modèle et un schéma | inféré du modèle | nouveau code bâti sur les signals |
Formulaires réactifs typés
Un formulaire réactif est un arbre d'objets construit dans la classe : FormControl<T> pour une valeur, FormGroup pour un objet, FormArray pour une liste. Le template ne fait que s'y attacher, par [formGroup] et formControlName. Depuis Angular 14, ces classes sont génériques et le type se déduit de la valeur initiale ; mais ce type contient null, et c'est voulu. Le code suivant compile et donne un total faux après « Recommencer » :
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-commande',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="commande">
<input formControlName="client" />
<input type="number" formControlName="quantite" />
<p>Total : {{ total() }} EUR</p>
<button type="button" (click)="commande.reset()">Recommencer</button>
</form>
`,
})
export class Commande {
protected readonly commande = new FormGroup({
client: new FormControl(''), // FormControl<string | null>
quantite: new FormControl(1), // FormControl<number | null>
});
protected total(): number {
// value est un Partial<{ client: string | null; quantite: number | null }>.
// Le ! fait taire le compilateur, pas le probleme : reset() remet chaque
// controle a null, et non a sa valeur initiale. Apres « Recommencer »,
// quantite vaut null, et null * 12 affiche un total de 0.
return this.commande.value.quantite! * 12;
}
}reset() ramène un contrôle à null, sauf s'il a été créé avec nonNullable: true, et le type le dit : new FormControl(1) est un FormControl<number | null>. NonNullableFormBuilder applique l'option à tous les contrôles qu'il crée, et la notation en tableau y range la valeur initiale puis les validateurs.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-commande',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="commande">
<input formControlName="client" />
<input type="number" formControlName="quantite" />
<p>Total : {{ total() }} EUR</p>
<button type="button" (click)="commande.reset()">Recommencer</button>
</form>
`,
})
export class Commande {
// Chaque controle cree par ce builder est { nonNullable: true } : son type
// exclut null, et reset() le ramene a sa valeur initiale.
private readonly fb = inject(NonNullableFormBuilder);
protected readonly commande = this.fb.group({
client: ['', Validators.required], // FormControl<string>
quantite: [1, [Validators.required, Validators.min(1)]], // FormControl<number>
});
protected total(): number {
// getRawValue() : { client: string; quantite: number }, controles
// desactives compris ; value, lui, reste un Partial parce qu'il les omet.
return this.commande.getRawValue().quantite * 12;
}
} Deux limites restent. value est toujours un Partial, parce qu'il omet les contrôles désactivés ; getRawValue() rend l'objet complet. Et le type ne décrit que ce que le code écrit : un input type="number" vidé par l'utilisateur écrit null dans un FormControl<number>. C'est Validators.required qui le signale, pas le compilateur.
FormArray
FormArray tient une liste de contrôles de même forme, dont la longueur change à l'exécution : push, insert, removeAt, clear. Dans le template, formArrayName désigne le tableau et chaque élément s'y rattache par son indice, [formGroupName]="i". Pour des clés dynamiques plutôt que des positions, FormRecord joue le même rôle qu'un dictionnaire. Le piège tient au suivi de la boucle : suivies par $index, les lignes affichent une valeur retirée du modèle.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-lignes',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="commande">
<div formArrayName="lignes">
<!-- Lignes A puis B ; on retire A. Suivi par $index, le fieldset
d'indice 0 est conserve, celui d'indice 1 est detruit. Les champs
conserves restent relies aux controles de A, qui ne sont plus dans
le tableau : l'ecran affiche A, la valeur vaut [B], et la saisie ne
va nulle part. -->
@for (ligne of lignes.controls; track $index; let i = $index) {
<fieldset [formGroupName]="i">
<input formControlName="reference" />
<input type="number" formControlName="quantite" />
<button type="button" (click)="lignes.removeAt(i)">Retirer</button>
</fieldset>
}
</div>
</form>
`,
})
export class Lignes {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly commande = this.fb.group({
lignes: this.fb.array([this.ligne('A'), this.ligne('B')]),
});
protected readonly lignes = this.commande.controls.lignes;
private ligne(reference: string) {
return this.fb.group({ reference, quantite: 1 });
}
} Chaque formControlName relie son champ à un FormControl précis au moment où il est créé, et retirer une ligne du FormArray ne relie pas à nouveau les champs conservés. Suivre chaque ligne par son FormGroup détruit le bon fieldset, et ceux qui restent sont reliés à des contrôles qui sont toujours dans le tableau. Ici l'objet est la bonne clé, bien que le cours Bases conseille une identité métier : un FormGroup n'est jamais recréé, et c'est lui, non sa valeur, qui porte l'identité de la ligne.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-lignes',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="commande">
<div formArrayName="lignes">
<!-- Chaque ligne est suivie par son FormGroup : retirer la premiere
detruit son fieldset, et les autres gardent le leur. -->
@for (ligne of lignes.controls; track ligne; let i = $index) {
<fieldset [formGroupName]="i">
<input formControlName="reference" />
<input type="number" formControlName="quantite" />
<button type="button" (click)="lignes.removeAt(i)">Retirer</button>
</fieldset>
}
</div>
<button type="button" (click)="ajouter()">Ajouter une ligne</button>
@if (lignes.hasError('required')) {
<p>Une commande contient au moins une ligne.</p>
}
</form>
`,
})
export class Lignes {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly commande = this.fb.group({
// required et non minLength(1) : les validateurs de longueur laissent passer
// une valeur vide, et un tableau vide en est une.
lignes: this.fb.array([this.nouvelleLigne()], Validators.required),
});
// FormArray<FormGroup<{ reference: FormControl<string>; quantite: FormControl<number> }>>
protected readonly lignes = this.commande.controls.lignes;
protected ajouter(): void {
this.lignes.push(this.nouvelleLigne());
}
private nouvelleLigne() {
return this.fb.group({
reference: ['', Validators.required],
quantite: [1, Validators.min(1)],
});
}
} Le validateur du tableau appelle la même prudence. Validators.minLength(1) sur un FormArray vide le déclare valide : minLength laisse passer les valeurs vides, et un tableau sans élément en est une. Validators.required le refuse.
Validateurs synchrones et de groupe
Un validateur est une fonction ValidatorFn : elle reçoit le contrôle et rend null si la règle est respectée, sinon un objet ValidationErrors dont chaque clé nomme une erreur et porte ce qu'il faut pour l'expliquer. Trois conventions les rendent composables. Un validateur ignore la valeur vide, que traite required. Un validateur paramétré est une fabrique qui rend la fonction, comme Validators.min(1). Un validateur de groupe se passe au FormGroup dans l'option validators, reçoit le groupe entier, et peut donc comparer deux champs.
// validateurs.ts
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
// Un validateur est une fonction pure : le controle en entree, null si la
// regle est respectee, sinon un objet dont chaque cle nomme une erreur.
export function formatReference(control: AbstractControl<string>): ValidationErrors | null {
// Une valeur vide n'est pas son affaire : c'est le role de required, et
// c'est ce qui permet de combiner les deux sans message en double.
if (!control.value) {
return null;
}
return /^[A-Z]{3}-\d{3}$/.test(control.value)
? null
: { formatReference: { attendu: 'ABC-123' } };
}
// Un validateur parametre est une fabrique qui rend un ValidatorFn.
export function multipleDe(pas: number): ValidatorFn {
return (control) => {
// Meme convention : un input type="number" vide donne null, affaire de required.
if (control.value == null || control.value === '') {
return null;
}
return control.value % pas === 0 ? null : { multipleDe: { pas, reste: control.value % pas } };
};
}
// Un validateur de groupe recoit le FormGroup, et peut donc comparer deux champs.
// Les dates d'un input type="date" sont des chaines AAAA-MM-JJ, que l'ordre
// alphabetique range comme le calendrier.
export const periodeCoherente: ValidatorFn = (groupe) => {
const debut: string = groupe.get('debut')?.value ?? '';
const fin: string = groupe.get('fin')?.value ?? '';
return debut && fin && fin < debut ? { periode: { debut, fin } } : null;
};
// Resultats :
// formatReference sur 'abc' -> { formatReference: { attendu: 'ABC-123' } }
// formatReference sur '' -> null
// multipleDe(5) sur 12 -> { multipleDe: { pas: 5, reste: 2 } }
// periodeCoherente sur un groupe du 2026-10-10 au 2026-10-01
// errors du groupe -> { periode: { debut: '2026-10-10', fin: '2026-10-01' } }
// errors du controle fin -> null La dernière ligne des résultats compte pour la suite : l'erreur d'un validateur de groupe appartient au groupe, pas au champ qui l'a provoquée. Pour un formulaire template-driven, la même fonction s'emploie en l'enveloppant dans une directive d'attribut qui la fournit sous le jeton NG_VALIDATORS.
Validateurs asynchrones
Une règle qui demande le serveur — une référence qui existe, un identifiant libre — est un AsyncValidatorFn. Il rend une promesse ou un observable qui se termine, et ne s'exécute que si les validateurs synchrones du contrôle passent. Pendant l'attente, le statut vaut 'PENDING' : pending est vrai, valid et invalid sont faux tous les deux. Les exemples s'appuient sur ce service :
// catalogue.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class Catalogue {
appels = 0;
// Tient lieu d'un appel HTTP : 200 ms d'aller-retour.
referenceExiste(reference: string): Promise<boolean> {
this.appels++;
return new Promise((resoudre) =>
setTimeout(() => resoudre(['ABC-123', 'XYZ-999'].includes(reference)), 200),
);
}
}L'erreur classique consiste à appeler le serveur directement : le validateur s'exécute à chaque changement de valeur, donc à chaque touche.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AsyncValidatorFn, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { Catalogue } from './catalogue';
// Rend une promesse ou un observable qui se termine : null, ou des erreurs.
export function referenceConnue(catalogue: Catalogue): AsyncValidatorFn {
return async (control) =>
(await catalogue.referenceExiste(control.value)) ? null : { referenceInconnue: true };
}
@Component({
selector: 'app-reference',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input [formControl]="reference" />
@if (statut() === 'PENDING') {
<p>Verification...</p>
} @else if (statut() === 'INVALID' && reference.hasError('referenceInconnue')) {
<p>Reference inconnue.</p>
}
`,
})
export class Reference {
protected readonly reference = new FormControl('', {
nonNullable: true,
validators: Validators.required,
asyncValidators: referenceConnue(inject(Catalogue)),
});
// Le statut affiche passe par un signal ; la raison est donnee apres la
// version juste. Ce n'est pas ce qui cloche ici.
protected readonly statut = toSignal(this.reference.statusChanges, {
initialValue: this.reference.status,
});
}
// Saisie de « ABC-123 », une touche toutes les 50 ms : 7 valeurs, 7 appels au
// serveur. Le resultat affiche est juste, parce que chaque nouvelle valeur
// abandonne la verification precedente ; les six requetes abandonnees sont
// pourtant parties, et le serveur les a traitees.Le remède tient à une propriété du contrôle : à chaque nouvelle valeur, il se désabonne de la vérification en cours. Un observable qui commence par attendre est donc annulé tant que la saisie continue.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AsyncValidatorFn, FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { map, switchMap, timer } from 'rxjs';
import { Catalogue } from './catalogue';
export function referenceConnue(catalogue: Catalogue, delai = 300): AsyncValidatorFn {
// L'observable commence par attendre. Une nouvelle valeur avant la fin du
// delai desabonne la verification en cours, ce qui annule le minuteur :
// la requete ne part que pour la valeur sur laquelle la saisie s'arrete.
return (control) =>
timer(delai).pipe(
switchMap(() => catalogue.referenceExiste(control.value)),
map((existe) => (existe ? null : { referenceInconnue: true })),
);
}
@Component({
selector: 'app-reference',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input [formControl]="reference" />
@if (statut() === 'PENDING') {
<p>Verification...</p>
} @else if (statut() === 'INVALID' && reference.hasError('referenceInconnue')) {
<p>Reference inconnue.</p>
}
`,
})
export class Reference {
protected readonly reference = new FormControl('', {
nonNullable: true,
validators: Validators.required,
asyncValidators: referenceConnue(inject(Catalogue)),
});
// La reponse arrive hors de tout evenement du template : c'est ce signal,
// lu par le template, qui previent Angular que la vue est a rafraichir.
protected readonly statut = toSignal(this.reference.statusChanges, {
initialValue: this.reference.status,
});
}
// Meme saisie : 7 valeurs, 1 appel, parti 300 ms apres la derniere touche. L'option updateOn: 'blur' fait le même travail autrement, en ne mettant à jour la valeur qu'à la sortie du champ, au prix d'un retour plus tardif sur les autres règles.
La réponse du serveur arrive après coup, hors de tout événement du template, et rien dans l'API des formulaires réactifs ne promet qu'elle rafraîchisse un composant OnPush sans Zone.js. Le guide zoneless d'angular.dev v22 le dit : setValue, patchValue ou push mettent à jour l'état du formulaire et émettent ses observables, sans planifier de détection de changements. Les getters status, pending ou value ne sont pas des signals, et le template qui les lit ne s'abonne à rien. D'où le toSignal(reference.statusChanges) des deux exemples : c'est la règle du cours Signals, ce qui s'affiche est un signal, une entrée ou le résultat d'un pipe async.
Sans ce signal, l'écran se met souvent à jour quand même, par un détail interne de 22.1.7 : la directive qui pose les classes ng-* sur l'input lié lit un signal privé du statut. Un contrôle affiché sans input lié n'en profite pas, ni une valeur changée par setValue sans changement de statut, et rien n'engage Angular à garder ce détail.
Afficher les erreurs
Un contrôle expose ses erreurs par errors, hasError(clé) et getError(clé), qui rend l'objet posé par le validateur. Il expose aussi son histoire : touched après la sortie du champ, dirty après une modification. Les directives de formulaire en tirent des classes CSS posées sur chaque élément lié : ng-valid, ng-invalid ou ng-pending, ng-touched ou ng-untouched, ng-dirty ou ng-pristine. L'erreur de groupe est l'endroit où l'affichage se trompe le plus souvent : on la cherche sur le champ qu'elle concerne, où elle n'est pas.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule } from '@angular/forms';
import { periodeCoherente } from './validateurs';
@Component({
selector: 'app-livraison',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="commande">
<fieldset formGroupName="livraison">
<input type="date" formControlName="debut" />
<input type="date" formControlName="fin" />
<!-- L'erreur est cherchee sur fin, alors que periodeCoherente la pose
sur le groupe : ce message ne s'affiche jamais. -->
@if (commande.controls.livraison.controls.fin.hasError('periode')) {
<p>La fin precede le debut.</p>
}
</fieldset>
</form>
`,
})
export class Livraison {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly commande = this.fb.group({
livraison: this.fb.group({ debut: '', fin: '' }, { validators: periodeCoherente }),
});
} Le champ fin reste d'ailleurs ng-valid ; seuls le fieldset du groupe et le form qui le contient reçoivent ng-invalid, et c'est le fieldset qu'une règle de style doit viser. La version juste lit l'erreur sur le groupe, et règle au passage le moment de l'affichage — à la sortie du champ, ou à la soumission pour les champs jamais visités.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
AbstractControl,
NonNullableFormBuilder,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { formatReference, multipleDe, periodeCoherente } from './validateurs';
@Component({
selector: 'app-livraison',
imports: [ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<form [formGroup]="commande" (ngSubmit)="envoyer()">
<input formControlName="reference" />
@let reference = commande.controls.reference;
@if (aAfficher(reference)) {
@if (reference.hasError('required')) {
<p>La reference est obligatoire.</p>
} @else if (reference.getError('formatReference'); as erreur) {
<p>Format attendu : {{ erreur.attendu }}.</p>
}
}
<input type="number" formControlName="quantite" />
@let quantite = commande.controls.quantite;
@if (aAfficher(quantite)) {
@if (quantite.hasError('required')) {
<p>La quantite est obligatoire.</p>
} @else if (quantite.getError('multipleDe'); as erreur) {
<p>Par cartons de {{ erreur.pas }} : retirez {{ erreur.reste }} ou completez.</p>
}
}
<fieldset formGroupName="livraison">
<input type="date" formControlName="debut" />
<input type="date" formControlName="fin" />
<!-- L'erreur est lue la ou le validateur l'a posee : sur le groupe. -->
@let livraison = commande.controls.livraison;
@if (aAfficher(livraison) && livraison.hasError('periode')) {
<p>La fin precede le debut.</p>
}
</fieldset>
<button type="submit">Commander</button>
</form>
`,
})
export class Livraison {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly commande = this.fb.group({
reference: ['', [Validators.required, formatReference]],
quantite: [10, [Validators.required, multipleDe(5)]],
livraison: this.fb.group({ debut: '', fin: '' }, { validators: periodeCoherente }),
});
// Une erreur ne s'affiche qu'une fois le champ quitte : personne ne veut
// lire « obligatoire » avant d'avoir commence a taper. Un groupe est touched
// des qu'un de ses champs l'est.
protected aAfficher(control: AbstractControl): boolean {
return control.invalid && control.touched;
}
protected envoyer(): void {
if (this.commande.invalid) {
// Celui qui soumet sans avoir tout rempli doit voir toutes les erreurs,
// y compris celles des champs qu'il n'a jamais visites.
this.commande.markAllAsTouched();
return;
}
console.log(this.commande.getRawValue());
}
}dirty sert aussi en dehors du formulaire. Le guard brouillonGuard du cours Routage demande au composant quitté s'il a un brouillon ; la méthode aUnBrouillon() d'un formulaire réactif peut se contenter de rendre this.commande.dirty.
Signal Forms : le modèle est un signal
Signal Forms, dans @angular/forms/signals, est apparu en Angular 21 comme API expérimentale. Au 26 septembre 2026, avec @angular/forms 22.1.7, il est stable : ses exports sont marqués @publicApi 22.0, sans la mention @experimental, et la feuille de route d'angular.dev le range parmi les projets terminés. Seul provideExperimentalWebMcpForms, qui expose un formulaire à un agent d'IA, reste expérimental. La documentation n'a pas suivi partout : la dernière page du tutoriel Signal Forms d'angular.dev v22 le dit encore expérimental.
Le renversement tient en une ligne : form(modele) ne copie pas la valeur, il construit au-dessus du signal un arbre de champs de même forme. Appeler un champ, formulaire.client(), donne son état, dont chaque propriété est un signal : value, écrivable et relié au modèle, valid, invalid, errors, touched, dirty, pending. La directive [formField] lie un champ à un élément, avec ses attributs required, disabled ou readonly. Les règles vivent dans un schéma, une fonction exécutée une fois qui les pose sur des chemins, et applyEach étend un sous-schéma à chaque élément d'un tableau.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { applyEach, form, FormField, FormRoot, min, required } from '@angular/forms/signals';
interface Ligne {
reference: string;
quantite: number;
}
interface Commande {
client: string;
lignes: Ligne[];
}
@Component({
selector: 'app-commande',
imports: [FormField, FormRoot],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- formRoot empeche l'envoi natif et appelle submit() sur le formulaire. -->
<form [formRoot]="formulaire">
<input [formField]="formulaire.client" />
@if (formulaire.client().touched() && formulaire.client().invalid()) {
@for (erreur of formulaire.client().errors(); track erreur.kind) {
<p>{{ erreur.message }}</p>
}
}
<!-- Un tableau du modele se parcourt comme un tableau de champs ; chaque
champ garde son identite quand on retire une ligne avant lui. -->
@for (ligne of formulaire.lignes; track ligne; let i = $index) {
<fieldset>
<input [formField]="ligne.reference" />
<input type="number" [formField]="ligne.quantite" />
<button type="button" (click)="retirer(i)">Retirer</button>
</fieldset>
}
<button type="button" (click)="ajouter()">Ajouter une ligne</button>
<button type="submit">Commander</button>
</form>
`,
})
export class CommandeSignal {
// La source de verite est ce signal, qui appartient au composant ; form() ne
// copie rien, il construit un arbre de champs au-dessus de lui.
protected readonly modele = signal<Commande>({
client: '',
lignes: [{ reference: '', quantite: 1 }],
});
protected readonly formulaire = form(
this.modele,
// Le schema : les regles sont posees sur des chemins, une fois, a la creation.
(chemin) => {
required(chemin.client, { message: 'Le client est obligatoire.' });
// applyEach applique un sous-schema a chaque element, present ou futur.
applyEach(chemin.lignes, (ligne) => {
required(ligne.reference, { message: 'Reference obligatoire.' });
// min laisse passer le null d'un input number vide : required le refuse.
required(ligne.quantite, { message: 'Quantite obligatoire.' });
min(ligne.quantite, 1, { message: 'Au moins une unite.' });
});
},
{
submission: {
// submit() marque d'abord tous les champs touched, ce qui fait
// apparaitre les erreurs. Par defaut, l'action est appelee des que le
// formulaire n'est pas invalid(), meme pendant une verification
// asynchrone ; 'none' exige qu'il soit valid().
ignoreValidators: 'none',
action: async (champ) => {
console.log(champ().value());
},
},
},
);
protected ajouter(): void {
// Le modele se remplace, il ne se mute pas (voir le cours Signals).
this.modele.update((m) => ({ ...m, lignes: [...m.lignes, { reference: '', quantite: 1 }] }));
}
protected retirer(indice: number): void {
this.modele.update((m) => ({ ...m, lignes: m.lignes.filter((_, i) => i !== indice) }));
}
} Il n'existe plus de FormArray : un tableau du modèle se parcourt comme un tableau de champs. Pour un tableau d'objets, comme ces lignes, chaque champ garde son identité quand on retire une ligne avant lui ; ce n'est pas le cas d'un tableau de chaînes ou de nombres. La boucle se suit donc par le champ lui-même, et le piège de la section FormArray disparaît. Deux habitudes se perdent en route. Aucune classe ng-* n'est posée par défaut, sauf à fournir provideSignalFormsConfig({ classes: NG_STATUS_CLASSES }), la constante venant de @angular/forms/signals/compat. Et un input type="number" vidé écrit ici aussi null dans le modèle, que min laisse passer : c'est pourquoi la quantité porte aussi required, comme en réactif. Le compilateur, lui, en vérifie davantage : avec strictTemplates, un champ number lié à un input sans type="number" est refusé en TS2322.
Signal Forms : validation et interopérabilité
Les règles fournies — required, min, max, minLength, maxLength, pattern, email — acceptent un message, et une erreur est un objet { kind, message } que le template affiche tel quel. Une règle personnalisée s'écrit avec validate : elle reçoit le contexte du champ et rend null, une erreur ou une liste d'erreurs. Comme le contexte lit n'importe quel autre champ par valueOf, une règle croisée se pose sur le champ qui doit porter l'erreur, sans validateur de groupe ; validateTree couvre le cas où une règle posée haut doit viser un champ plus bas.
import { ChangeDetectionStrategy, Component, inject, resource, signal } from '@angular/core';
import {
form,
FormField,
pattern,
required,
validate,
validateAsync,
} from '@angular/forms/signals';
import { Catalogue } from './catalogue';
@Component({
selector: 'app-livraison',
imports: [FormField],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input [formField]="formulaire.reference" />
@if (formulaire.reference().pending()) {
<p>Verification...</p>
}
<input type="date" [formField]="formulaire.debut" />
<input type="date" [formField]="formulaire.fin" />
@for (erreur of formulaire().errorSummary(); track $index) {
<p>{{ erreur.message }}</p>
}
`,
})
export class LivraisonSignal {
private readonly catalogue = inject(Catalogue);
protected readonly modele = signal({ reference: '', debut: '', fin: '' });
protected readonly formulaire = form(this.modele, (chemin) => {
required(chemin.reference, { message: 'La reference est obligatoire.' });
pattern(chemin.reference, /^[A-Z]{3}-\d{3}$/, {
message: 'Format attendu : ABC-123.',
});
// Un validateur personnalise rend null, une erreur ou une liste d'erreurs.
// Pose sur fin, il lit debut par valueOf : l'erreur appartient a fin, et
// s'affiche a cote de lui, sans validateur de groupe a interroger.
validate(chemin.fin, ({ value, valueOf }) => {
const debut = valueOf(chemin.debut);
return debut && value() && value() < debut
? { kind: 'periode', message: 'La fin precede le debut.' }
: null;
});
// Ne s'execute que lorsque les regles synchrones du champ passent, et
// attend 300 ms sans frappe. Une nouvelle valeur annule la precedente.
validateAsync(chemin.reference, {
params: ({ value }) => value(),
debounce: 300,
factory: (reference) =>
resource({
params: reference,
loader: ({ params }) => this.catalogue.referenceExiste(params),
}),
onSuccess: (existe) =>
existe ? null : { kind: 'referenceInconnue', message: 'Reference inconnue.' },
onError: () => ({
kind: 'verification',
message: 'Verification impossible.',
}),
});
});
}validateAsync confie la vérification à une resource, et validateHttp à une httpResource pour le cas courant d'un appel HTTP. Les deux ne partent qu'une fois les règles synchrones du champ satisfaites, abandonnent la vérification périmée quand la valeur change, et acceptent un debounce qui attend une pause dans la frappe : sur un champ qui n'a que required, la saisie de « ABC-123 » lance sept vérifications sans lui, une seule avec 300 ms. Dans l'exemple, pattern écarte déjà les valeurs incomplètes. Pendant l'attente, valid() et invalid() sont faux ensemble, comme en réactif, et c'est là que se loge un piège. submit() exécute l'action dès que le formulaire n'est pas invalid() : une vérification en cours ne le retient pas. Le guide « Async operations » d'angular.dev v22 affirme qu'il attend la fin de la validation ; le code de 22.1.7 ne le fait pas. L'option ignoreValidators: 'none', posée dans submission comme dans l'exemple de la section précédente ou passée à submit(formulaire, { action, ignoreValidators: 'none' }), refuse l'envoi tant que le formulaire n'est pas valid(), sans attendre non plus : l'utilisateur soumet à nouveau une fois la vérification terminée. La documentation propose en outre de désactiver le bouton d'envoi pendant l'attente, [disabled]="formulaire().pending()".
Un formulaire réactif existant ne se réécrit pas d'un bloc. Le point d'entrée @angular/forms/signals/compat propose les deux sens : compatForm accepte des FormControl à l'intérieur d'un modèle signal, et SignalFormControl est un contrôle régi par des règles Signal Forms qui prend place dans un FormGroup existant. La migration peut alors avancer champ par champ.