Skip to content

Vous souhaitez recevoir de l'aide sur ce sujet ? rejoignez la communauté Angular.fr sur Discord.

Créer un formulaire avec les Signal Forms d'Angular 22

Les Signal Forms sont stables depuis Angular 22. Elles permettent de construire un formulaire à partir d'un modèle stocké dans un Signal, puis d'y associer validation, état et soumission de manière déclarative.

Ce tutoriel crée un formulaire de connexion. Il couvre le modèle, les règles de validation, l'affichage des erreurs et la soumission. Les exemples utilisent Angular 22.1.6, la version stable la plus récente à la rédaction de cet article.

À connaître avant de commencer

Les Signal Forms nécessitent Angular 21 ou plus récent et sont importées depuis @angular/forms/signals. Elles sont particulièrement adaptées aux nouvelles applications déjà orientées Signals. Les Reactive Forms restent une solution stable et pertinente pour les applications existantes.

Le modèle est la source de vérité

Un formulaire a besoin de conserver ses valeurs, son état de validation et son état d'interaction. Avec les Signal Forms, les valeurs sont contenues dans un WritableSignal. La fonction form() construit une structure de champs typée au-dessus de ce modèle.

ts
import { Component, signal } from '@angular/core';
import { form } from '@angular/forms/signals';

type LoginData = {
  email: string;
  password: string;
  rememberMe: boolean;
};

@Component({
  selector: 'app-login',
  template: '',
})
export class LoginComponent {
  readonly loginModel = signal<LoginData>({
    email: '',
    password: '',
    rememberMe: false,
  });

  readonly loginForm = form(this.loginModel);
}

loginModel contient les données métier du formulaire. loginForm apporte une vue structurée de ces données : loginForm.email, loginForm.password et loginForm.rememberMe donnent chacun accès à la valeur et à l'état de leur champ.

Il n'y a pas deux copies à synchroniser. Si un champ modifie sa valeur, loginModel() est mis à jour. Si le composant remplace le modèle, le formulaire reflète ce changement.

Relier un champ HTML au formulaire

La directive FormField connecte un champ natif à une branche de FieldTree. Il faut l'ajouter aux imports du composant standalone.

ts
import { Component, signal } from '@angular/core';
import { form, FormField } from '@angular/forms/signals';

@Component({
  selector: 'app-login',
  imports: [FormField],
  template: `
    <label>
      Adresse e-mail
      <input type="email" [formField]="loginForm.email" />
    </label>

    <label>
      Mot de passe
      <input type="password" [formField]="loginForm.password" />
    </label>

    <label>
      <input type="checkbox" [formField]="loginForm.rememberMe" />
      Rester connecté
    </label>
  `,
})
export class LoginComponent {
  readonly loginModel = signal({ email: '', password: '', rememberMe: false });
  readonly loginForm = form(this.loginModel);
}

[formField] assure la liaison bidirectionnelle entre le contrôle HTML et le modèle. Le type du modèle guide également l'accès aux champs : une faute de frappe dans loginForm.emial est détectée par TypeScript.

Déclarer les règles au même endroit

La validation s'ajoute dans une fonction de schéma passée à form(). Le schéma reçoit un chemin typé vers les champs et les règles sont calculées de façon réactive.

ts
import { email, form, minLength, required } from '@angular/forms/signals';

readonly loginForm = form(this.loginModel, (path) => {
  required(path.email, { message: 'Saisissez votre adresse e-mail.' });
  email(path.email, { message: 'Cette adresse e-mail est invalide.' });

  required(path.password, { message: 'Saisissez votre mot de passe.' });
  minLength(path.password, 12, { message: 'Le mot de passe doit contenir 12 caractères.' });
});

Les règles synchrones se recalculent lorsque la valeur change. Les informations du champ sont elles aussi des Signals : loginForm.email().invalid(), loginForm.email().touched() et loginForm.email().errors() se lisent directement dans le template.

L'exemple suivant évite d'afficher une erreur avant la première interaction avec le champ :

html
@if (loginForm.email().touched() && loginForm.email().invalid()) {
  <p class="error" role="alert">
    @for (error of loginForm.email().errors(); track error.kind) {
      {{ error.message }}
    }
  </p>
}

Les Signal Forms ne s'appuient pas sur les pseudo-classes CSS natives :valid et :invalid pour exprimer leur état. Pour styliser un champ, utilisez les Signals d'état du champ ou configurez les classes d'état automatiques proposées par l'API.

Soumettre avec FormRoot

FormRoot est la manière la plus simple de relier un élément <form> à une action asynchrone. Il empêche la soumission navigateur par défaut, ajoute novalidate, marque les champs interactifs comme touchés et ne lance l'action que si le formulaire est valide.

ts
import { Component, signal } from '@angular/core';
import {
  email,
  form,
  FormField,
  FormRoot,
  required,
} from '@angular/forms/signals';

type LoginData = { email: string; password: string };

@Component({
  selector: 'app-login',
  imports: [FormField, FormRoot],
  template: `
    <form [formRoot]="loginForm">
      <label>
        Adresse e-mail
        <input type="email" [formField]="loginForm.email" />
      </label>

      <label>
        Mot de passe
        <input type="password" [formField]="loginForm.password" />
      </label>

      <button type="submit" [disabled]="loginForm().submitting()">
        @if (loginForm().submitting()) {
          Connexion en cours…
        } @else {
          Se connecter
        }
      </button>
    </form>
  `,
})
export class LoginComponent {
  readonly loginModel = signal<LoginData>({ email: '', password: '' });

  readonly loginForm = form(
    this.loginModel,
    (path) => {
      required(path.email);
      email(path.email);
      required(path.password);
    },
    {
      submission: {
        action: async (field) => {
          const response = await fetch('/api/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(field().value()),
          });

          if (response.ok) return;

          return {
            kind: 'authentication',
            message: 'Adresse e-mail ou mot de passe incorrect.',
          };
        },
      },
    },
  );
}

Pendant l'appel asynchrone, loginForm().submitting() vaut true. Les tentatives de soumission concurrentes sont ignorées. Si l'action retourne une erreur, elle est ajoutée aux erreurs du formulaire ; elle est ensuite effacée lorsqu'un utilisateur modifie le champ concerné.

Pour une erreur à placer sur un champ précis, retournez aussi fieldTree :

ts
return {
  kind: 'authentication',
  message: 'Cette adresse e-mail n’est pas reconnue.',
  fieldTree: field.email,
};

Signal Forms ou Reactive Forms ?

Les Signal Forms ne rendent pas les Reactive Forms obsolètes. Elles offrent un modèle plus naturel lorsque l'état de l'application est déjà exprimé avec des Signals et lorsque l'on construit un nouveau formulaire.

Gardez les Reactive Forms pour une application existante qui les utilise largement, pour les bibliothèques qui les attendent directement, ou quand une migration n'apporte pas de bénéfice concret. Angular fournit aussi des APIs de compatibilité pour faire cohabiter les deux approches progressivement.

Le choix utile n'est pas de réécrire tous les formulaires. Commencez par un nouveau flux isolé, vérifiez l'intégration de vos composants de champ et de vos tests, puis établissez une convention d'équipe si le modèle vous convient.

À retenir

Les Signal Forms reposent sur une idée simple : le modèle Signal est la source de vérité, et le formulaire expose une structure typée qui porte les règles et l'état. FormField lie les contrôles, le schéma centralise la validation et FormRoot organise la soumission.

Cette API est stable depuis Angular 22. Elle mérite d'être envisagée pour les nouveaux formulaires d'une application moderne, sans imposer une migration des formulaires réactifs existants.

Sources