Maîtriser les formulaires signal avec Angular 21+ 📝

Maîtriser les formulaires signal avec Angular 21+ 📝

LoĂŻc Boutter (lazybobcat)
LoĂŻc Boutter (lazybobcat)

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 :

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 :

tournament-registration.component.ts
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] :

tournament-registration.component.html
<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 de 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 :

Soumission du formulaire
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 :

Application des validateurs
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 :

Affichage des erreurs de validation
<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 validateur
  • field : 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 :

Désactiver le bouton si 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 :

Afficher un astérisque pour les champs requis
<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) :

Installation de Zod Mini
npm install @standard-schema/zod-mini

Définissons un schéma de validation dans notre fichier modèle :

tournament.model.ts - Schéma de validation Zod
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 :

Utilisation du schéma Zod dans le composant
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 :

Type-guard pour les erreurs Zod
import { ValidationError, StandardSchemaValidationError } from '@angular/forms/signals';
 
isStandardSchemaError(error: ValidationError): error is StandardSchemaValidationError {
  return error.kind === 'standardSchema';
}

Puis dans le template :

Affichage des erreurs Zod
@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 :

tournament.model.ts - Validation cross-field
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 :

Le composant reste simple
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 :

Bouton avec état de soumission
<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 :

Afficher un spinner pendant la soumission
@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 :

SignalDescription
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Ă©

Pour en savoir plus

Retour aux articles