Maîtriser les formulaires signal avec Angular 21+ 📝
Angular 21 introduit une nouvelle façon de créer des formulaires : les formulaires signal. Cette approche expérimentale transforme la gestion des formulaires en s’appuyant entièrement sur les signaux, offrant une réactivité native et une syntaxe simplifiée.
Fini les FormControl et FormGroup avec leurs observables complexes : place à une API déclarative où chaque champ,
chaque état (valide, en erreur, en cours de soumission) devient un signal.
⚠️ Les formulaires signal sont encore expérimentaux dans Angular 21. L’API peut évoluer avant la stabilisation.
Dans ce tutoriel, nous allons créer un formulaire d’inscription à un tournoi Pokémon en explorant tous les concepts des formulaires signal.
🎯 Créer un premier formulaire
Commençons par créer un formulaire simple permettant à un dresseur de s’inscrire à un tournoi Pokémon.
Le modèle de données
La première étape consiste à définir un type pour représenter les données du formulaire. Créons un fichier tournament.model.ts :
export interface TournamentRegistration {
trainerName: string;
team: string;
badges: number;
email: string;
}Ensuite, dans le composant, créons un signal typé qui contiendra les données du formulaire :
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';
import { TournamentRegistration } from './tournament.model';
@Component({
selector: 'app-tournament-registration',
// ...
})
export class TournamentRegistrationComponent {
registration = signal<TournamentRegistration>({
trainerName: '',
team: '',
badges: 0,
email: '',
});
registrationForm = form(this.registration);
}Le signal registration contient les valeurs initiales du formulaire. La fonction form() crée ensuite un
FieldTree qui représente la structure du formulaire et expose des signaux pour chaque champ.
Liaison avec le template
Pour lier les champs du formulaire au template, utilisez la directive [field] :
<form (submit)="register($event)">
<div>
<label for="trainerName">Nom du dresseur</label>
<input id="trainerName" [field]="registrationForm.trainerName" />
</div>
<div>
<label for="team">Équipe</label>
<select id="team" [field]="registrationForm.team">
<option value="">Choisir une équipe</option>
<option value="rouge">Équipe Rouge</option>
<option value="bleu">Équipe Bleue</option>
<option value="jaune">Équipe Jaune</option>
</select>
</div>
<div>
<label for="badges">Nombre de badges</label>
<input id="badges" type="number" [field]="registrationForm.badges" />
</div>
<div>
<label for="email">Email</label>
<input id="email" type="email" [field]="registrationForm.email" />
</div>
<button type="submit">S'inscrire au tournoi</button>
</form>C’est tout ! La directive [field] synchronise automatiquement l’input avec le signal correspondant, il suffit d’importer la directive Field :
import { Field } from '@angular/forms/signals';
@Component({
// ...
imports: [Field]
})📤 Soumettre le formulaire
Pour gérer la soumission, utilisez la fonction submit() qui valide le formulaire et exécute une action asynchrone :
import { submit } from '@angular/forms/signals';
export class TournamentRegistrationComponent {
// ...
protected async register(event: SubmitEvent) {
event.preventDefault();
await submit(this.registrationForm, async (form) => {
const data = form().value();
console.log('Inscription :', data);
// Simuler un appel HTTP
return this.tournamentService.register(data.trainerName, data.team, data.badges, data.email);
});
}
}La fonction passée à submit() doit retourner une Promise. Si votre service retourne un Observable, convertissez-le
avec firstValueFrom() de RxJS.
âś… Ajouter des validations simples
Angular fournit des validateurs déclaratifs pour les formulaires signal : required, minLength, maxLength, min,
max, email, pattern.
Ces validateurs s’appliquent lors de la création du formulaire :
import { form, required, minLength, min, email } from '@angular/forms/signals';
import { TournamentRegistration } from './tournament.model';
export class TournamentRegistrationComponent {
registration = signal<TournamentRegistration>({
trainerName: '',
team: '',
badges: 0,
email: '',
});
registrationForm = form(this.registration, (fields) => {
required(fields.trainerName, { message: 'Le nom du dresseur est requis' });
minLength(fields.trainerName, 3, { message: 'Le nom doit contenir au moins 3 caractères' });
required(fields.team, { message: 'Veuillez choisir une équipe' });
required(fields.badges, { message: 'Le nombre de badges est requis' });
min(fields.badges, 8, { message: 'Il faut au moins 8 badges pour participer' });
required(fields.email, { message: "L'email est requis" });
email(fields.email, { message: "L'email n'est pas valide" });
});
}Afficher les erreurs
Les erreurs de validation sont accessibles via la propriété errors() de chaque champ. Cette propriété retourne un
tableau d’objets ValidationError :
<div>
<label for="trainerName">Nom du dresseur</label>
<input id="trainerName" [field]="registrationForm.trainerName" />
@let trainerName = registrationForm.trainerName();
@if (trainerName.touched() && !trainerName.valid()) {
<div class="errors">
@for (error of trainerName.errors(); track error.kind) {
<p>{{ error.message }}</p>
}
</div>
}
</div>L’objet ValidationError contient :
kind: le type d’erreur (required,minLength, etc.)message: le message défini lors de l’application du validateurfield: référence au champ en erreur
Désactiver le bouton de soumission
Utilisez le signal valid du formulaire pour désactiver le bouton tant que le formulaire est invalide :
<button type="submit" [disabled]="!registrationForm().valid()">S'inscrire au tournoi</button>Marquer les champs requis
Les validateurs ajoutent des métadonnées aux champs. Utilisez metadata() pour afficher un astérisque sur les champs
obligatoires :
<label for="trainerName">
Nom du dresseur
@if (registrationForm.trainerName().metadata(REQUIRED)()) {
<span class="required">*</span>
}
</label>🛡️ Validation avancée avec Zod
Pour des validations plus complexes, vous pouvez utiliser un schéma de validation comme Zod. Angular supporte le standard
Standard Schema via la fonction validateStandardSchema().
Installons d’abord Zod Mini (une version légère de Zod) :
npm install @standard-schema/zod-miniDéfinissons un schéma de validation dans notre fichier modèle :
import * as z from '@standard-schema/zod-mini';
export interface TournamentRegistration {
trainerName: string;
team: string;
badges: number;
email: string;
}
export const tournamentRegistrationSchema = z.object({
trainerName: z.string().check(
z.minLength(3, {
message: () => 'Le nom doit contenir au moins 3 caractères',
})
),
team: z.string().check(
z.minLength(1, {
message: () => 'Veuillez choisir une équipe',
})
),
badges: z.number().check(
z.min(8, {
message: (issue) => `Il faut au moins ${issue.minimum} badges pour participer`,
})
),
email: z.string().check(
z.email({
message: () => "L'email n'est pas valide",
})
),
});Maintenant, dans le composant, utilisons simplement ce schéma :
import { validateStandardSchema } from '@angular/forms/signals';
import { TournamentRegistration, tournamentRegistrationSchema } from './tournament.model';
export class TournamentRegistrationComponent {
registration = signal<TournamentRegistration>({
trainerName: '',
team: '',
badges: 0,
email: '',
});
registrationForm = form(this.registration, (fields) => {
validateStandardSchema(fields, tournamentRegistrationSchema);
});
}Les erreurs générées par validateStandardSchema() sont de type StandardSchemaValidationError et contiennent une
propriété issue avec le message d’erreur.
Pour les afficher, créez un type-guard :
import { ValidationError, StandardSchemaValidationError } from '@angular/forms/signals';
isStandardSchemaError(error: ValidationError): error is StandardSchemaValidationError {
return error.kind === 'standardSchema';
}Puis dans le template :
@let trainerName = registrationForm.trainerName();
@if (trainerName.touched() && !trainerName.valid()) {
<div class="errors">
@for (error of trainerName.errors(); track error.kind) {
@if (isStandardSchemaError(error)) {
<p>{{ error.issue.message }}</p>
}
}
</div>
}Validation cross-field avec Zod
Zod permet de créer des validations qui dépendent de plusieurs champs. Par exemple, vérifions que l’équipe choisie correspond au nombre de badges. Modifions notre schéma dans le fichier modèle :
export const tournamentRegistrationSchema = z
.object({
trainerName: z.string().check(z.minLength(3)),
team: z.string().check(z.minLength(1)),
badges: z.number().check(z.min(8)),
email: z.string().check(z.email()),
})
.check(
// Validation personnalisée
z.custom((data) => {
if (data.team === 'rouge' && data.badges < 10) {
return { success: false, issue: { message: "L'équipe Rouge requiert au moins 10 badges" } };
}
return { success: true, value: data };
})
);Le code du composant reste inchangé, il suffit de réutiliser le schéma :
registrationForm = form(this.registration, (fields) => {
validateStandardSchema(fields, tournamentRegistrationSchema);
});⏳ Gérer l’état de soumission
Pendant la soumission asynchrone, le formulaire expose un signal submitting() pour afficher un indicateur de
chargement ou désactiver les actions :
<button type="submit" [disabled]="!registrationForm().valid() || registrationForm().submitting()">
@if (registrationForm().submitting()) {
<span>⏳ Inscription en cours...</span>
} @else {
<span>S'inscrire au tournoi</span>
}
</button>Vous pouvez également afficher un spinner dans le formulaire :
@if (registrationForm().submitting()) {
<div class="spinner">
<p>🔄 Envoi des données...</p>
</div>
}🎓 État du formulaire
Le FieldTree expose de nombreux signaux pour connaître l’état du formulaire et de chaque champ :
| Signal | Description |
|---|---|
value() | Valeur actuelle du champ |
valid() | Indique si le champ est valide |
invalid() | Indique si le champ est invalide |
touched() | Indique si le champ a été touché |
dirty() | Indique si le champ a été modifié |
pending() | Indique si des validateurs async sont en cours |
submitting() | Indique si le formulaire est en cours de soumission |
disabled() | Indique si le champ est désactivé |
errors() | Liste des erreurs du champ |
errorSummary() | Liste des erreurs du champ et de ses sous-champs |
Méthodes utiles
Les champs exposent également des méthodes pour modifier leur état :
reset(): remet le champ à l’état pristine et untouched (sans changer la valeur)markAsTouched(): marque le champ comme touchémarkAsDirty(): marque le champ comme modifié