Signal Forms dans Angular 22 : la révolution des formulaires, avant/après

Les formulaires dans Angular ont toujours été un sujet… disons, riche. Entre Reactive Forms (puissants mais verbeux) et Template-Driven Forms (simples mais limités), le développeur devait choisir son poison. Avec Signal Forms, Angular 22 propose une troisième voie qui combine le meilleur des deux mondes, en y ajoutant la réactivité fine des signaux. Voyons concrètement ce que ça change.

Le formulaire simple : inscription

Avant — Reactive Forms

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup',
  template: `
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <input formControlName="email" placeholder="Email" />
      <div *ngIf="form.get('email')?.invalid && form.get('email')?.touched">
        <span *ngIf="form.get('email')?.errors?.['required']">Email requis</span>
        <span *ngIf="form.get('email')?.errors?.['email']">Email invalide</span>
      </div>

      <input type="password" formControlName="password" placeholder="Mot de passe" />
      <div *ngIf="form.get('password')?.invalid && form.get('password')?.touched">
        <span *ngIf="form.get('password')?.errors?.['required']">Mot de passe requis</span>
        <span *ngIf="form.get('password')?.errors?.['minlength']">
          Minimum {{ form.get('password')?.errors?.['minlength'].requiredLength }} caractères
        </span>
      </div>

      <button type="submit" [disabled]="form.invalid">S'inscrire</button>
    </form>
  `,
  imports: [ReactiveFormsModule]
})
export class SignupComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      email: ['', [Validators.required, Validators.email]],
      password: ['', [Validators.required, Validators.minLength(8)]]
    });
  }

  onSubmit() {
    if (this.form.valid) {
      console.log(this.form.value);
    }
  }
}

Problèmes :

  • Accès aux contrôles par chaînes de caractères (form.get('email')) — pas de vérification à la compilation.
  • Messages d’erreur qui nécessitent des vérifications multiples et fragiles.
  • Verbeux : FormGroup, FormBuilder, Validators, binding [formGroup], formControlName
  • Typage faible : form.value retourne any ou un Partial.

Après — Signal Forms

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

@Component({
  selector: 'app-signup',
  template: `
    <form (submit)="onSubmit($event)">
      <input type="email" [formField]="f.email" placeholder="Email" />
      @if (f.email().invalid() && f.email().touched()) {
        <div>
          @for (error of f.email().errors(); track error.kind) {
            <span>{{ error.message }}</span>
          }
        </div>
      }

      <input type="password" [formField]="f.password" placeholder="Mot de passe" />
      @if (f.password().invalid() && f.password().touched()) {
        <div>
          @for (error of f.password().errors(); track error.kind) {
            <span>{{ error.message }}</span>
          }
        </div>
      }

      <button type="submit" [disabled]="f().invalid()">S'inscrire</button>
    </form>
  `,
  imports: [FormField]
})
export class SignupComponent {
  readonly model = signal({ email: '', password: '' });

  readonly f = form(this.model, schema => {
    required(schema.email, { message: 'Email requis' });
    email(schema.email, { message: 'Format email invalide' });
    required(schema.password, { message: 'Mot de passe requis' });
    minLength(schema.password, {
      message: () => `Minimum 8 caractères`,
      value: 8
    });
  });

  onSubmit(event: Event) {
    event.preventDefault();
    if (this.f().valid()) {
      console.log(this.f().value());
      // TypeScript infère automatiquement : { email: string; password: string }
    }
  }
}

Ce qui change :

  • Typage fort : f().value() retourne exactement le type du modèle, pas d’any.
  • Plus de chaînes magiques : f.email au lieu de form.get('email'). L’IDE autocomplète.
  • Messages d’erreur embarqués : les validateurs déclarent leur propre message. Les erreurs sont itérables.
  • Syntaxe unifiée : un seul form(), un seul concept.
  • Contrôle flow moderne : @if, @for à la place de *ngIf, *ngFor.

Et par rapport à ngModel (Template-Driven Forms) ? Mêmes gains. Avec ngModel, la validation vit dans les attributs HTML (required, email), les messages d’erreur sont à parser manuellement, le typage est inexistant, et l’état loading/erreur pour l’asynchrone est une usine à gaz. Signal Forms unifie tout ça : que vous veniez de Reactive Forms ou de ngModel, le constat est le même.

Validation croisée : confirmation de mot de passe

Avant — Reactive Forms

this.form = this.fb.group({
  password: ['', [Validators.required, Validators.minLength(8)]],
  confirmPassword: ['']
}, { validators: this.passwordsMatch });

// ...

passwordsMatch(group: FormGroup) {
  const password = group.get('password')?.value;
  const confirm = group.get('confirmPassword')?.value;
  return password === confirm ? null : { mismatch: true };
}

Le validateur est défini à part, il faut accéder aux contrôles par chaînes, et les erreurs apparaissent sur le groupe, pas sur le champ concerné.

Après — Signal Forms

this.f = form(this.model, (schema, checker) => {
  required(schema.password, { message: 'Mot de passe requis' });
  required(schema.confirmPassword, { message: 'Confirmation requise' });

  // Validation croisée déclarée directement sur le champ
  checker(schema.confirmPassword, (value, formValue) => {
    if (value !== formValue.password) {
      return { kind: 'mismatch', message: 'Les mots de passe ne correspondent pas' };
    }
    return null;
  });
});

La validation croisée est déclarée sur le champ concerné, avec accès complet à la valeur du formulaire via formValue. L’erreur est localisée, pas noyée sur le groupe.

Champs dynamiques : liste de compétences

Avant — Reactive Forms avec FormArray

this.form = this.fb.group({
  name: ['', Validators.required],
  skills: this.fb.array([])
});

get skills(): FormArray {
  return this.form.get('skills') as FormArray;
}

addSkill() {
  this.skills.push(this.fb.control('', Validators.required));
}

removeSkill(index: number) {
  this.skills.removeAt(index);
}

Dans le template :

<div *ngFor="let skill of skills.controls; let i = index" [formGroupName]="i">
  <input [formControlName]="skill" />
  <button (click)="removeSkill(i)">Supprimer</button>
</div>

Après — Signal Forms

import { applyEach } from '@angular/forms/signals';

this.f = form(this.model, schema => {
  required(schema.name, { message: 'Nom requis' });
  // applyEach() itère sur le tableau et applique les validateurs à chaque élément
  applyEach(schema.skills, (skill) => {
    required(skill, { message: 'Compétence obligatoire' });
  });
});

addSkill() {
  // On manipule directement le signal modèle
  this.model.update(m => ({
    ...m,
    skills: [...m.skills, '']
  }));
}

removeSkill(index: number) {
  this.model.update(m => ({
    ...m,
    skills: m.skills.filter((_, i) => i !== index)
  }));
}

Template :

@for (skill of model().skills; track $index; let i = $index) {
  <input [formField]="f.skills.at(i)" />
  <button (click)="removeSkill(i)">Supprimer</button>
}

Plus besoin de FormArray, formGroupName ou de cast. L’ajout/suppression se fait directement sur le signal modèle, et applyEach() applique automatiquement la validation à chaque élément.

Async : vérification de disponibilité du pseudo

Avant — Validateur asynchrone Reactive Forms

this.form = this.fb.group({
  username: ['', [Validators.required], [this.usernameValidator.bind(this)]]
});

usernameValidator(control: FormControl) {
  return this.http.get(`/api/check/${control.value}`).pipe(
    map(exists => exists ? { taken: true } : null),
    debounceTime(300),
    first()
  );
}

Problèmes : le this binding, la gestion du debounce à la main, le mélange RxJS/validateur.

Après — Signal Forms

import { validateHttp } from '@angular/forms/signals';

this.f = form(this.model, (schema, checker) => {
  required(schema.username, { message: 'Pseudo requis' });

  validateHttp(schema.username, {
    debounce: 300,
    request: ({ value }) => `/api/check/${value()}`,
    onSuccess: (response: { taken: boolean }) =>
      response.taken
        ? { kind: 'taken', message: 'Pseudo déjà pris' }
        : null,
    onError: () => ({
      kind: 'networkError',
      message: 'Impossible de vérifier le pseudo',
    }),
  });
});

Le debounce est intégré (debounce: 300 dans les options), l’API est native et déclarative (request/onSuccess/onError), la logique est colocalisée.

Contrôle custom : l’équivalent du ControlValueAccessor

Quand on crée un composant de formulaire sur mesure (date picker, éditeur riche, toggle…), il faut le connecter au système de formulaires. Avant, c’était le ControlValueAccessor, une interface verbeuse et procédurale. Signal Forms remplace ça par des interfaces à base de signaux.

Avant — ControlValueAccessor

import { Component, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-rating',
  template: `
    <div class="rating">
      <button *ngFor="let star of stars; let i = index"
              (click)="select(i + 1)"
              [class.active]="i < value">
        ★
      </button>
    </div>
  `,
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => RatingControl),
    multi: true
  }]
})
export class RatingControl implements ControlValueAccessor {
  value = 0;
  disabled = false;
  stars = Array(5).fill(0);

  // Fonctions enregistrées par Angular — c'est du callback spaghetti
  private onChange: (value: number) => void = () => {};
  private onTouched: () => void = () => {};

  // Angular appelle cette méthode pour donner la valeur initiale
  writeValue(value: number): void {
    this.value = value;
  }

  // Angular enregistre sa fonction de callback
  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }

  // Angular enregistre sa fonction de callback pour le "touched"
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  // Angular dit au contrôle s'il est désactivé
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  select(rating: number): void {
    this.value = rating;
    this.onChange(rating); // Il faut penser à appeler le callback manuellement
    this.onTouched();
  }
}

Problèmes du ControlValueAccessor :

  • Callback spaghetti : onChange et onTouched sont des fonctions stockées dans des variables, assignées par Angular après coup.
  • Boilerplate obligatoire : NG_VALUE_ACCESSOR, forwardRef, providers: [...], 4 méthodes à implémenter.
  • Pas de réactivité : tout est impératif, aucune intégration native avec les signaux.
  • Pas d’état formulaire : pour afficher les erreurs, il faut les passer manuellement en @Input().

Après — FormValueControl (Signal Forms)

import { Component, model, input } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'app-rating',
  template: `
    <div class="rating" [class.disabled]="disabled()">
      @for (star of stars; track $index; let i = $index) {
        <button (click)="select(i + 1)"
                [class.active]="i < value()"
                [disabled]="disabled()">
          ★
        </button>
      }
      @if (invalid()) {
        <div class="error">
          @for (error of errors(); track error) {
            <span>{{ error.message }}</span>
          }
        </div>
      }
    </div>
  `,
  // Plus besoin de NG_VALUE_ACCESSOR ni de forwardRef !
})
export class RatingControl implements FormValueControl<number> {
  stars = Array(5).fill(0);

  // Le model() remplace writeValue + registerOnChange
  value = model(0);

  // L'état du formulaire arrive automatiquement via input()
  touched = model(false);
  disabled = input(false);
  invalid = input(false);
  errors = input<readonly { message: string }[]>([]);

  select(rating: number): void {
    this.value.set(rating);  // Un seul appel, tout est synchronisé
    this.touched.set(true);   // Le formulaire sait que l'utilisateur a interagi
  }
}

Utilisation dans un formulaire :

<form>
  <label>Votre note :
    <!-- Le [formField] suffit, tout est connecté automatiquement -->
    <app-rating [formField]="reviewForm.rating" />
  </label>

  @if (reviewForm.rating().invalid() && reviewForm.rating().touched()) {
    <div>
      @for (error of reviewForm.rating().errors(); track error.kind) {
        <span>{{ error.message }}</span>
      }
    </div>
  }
</form>

Ce qui change radicalement :

  • Plus de NG_VALUE_ACCESSOR : l’interface FormValueControl est détectée automatiquement par la directive [formField].
  • Plus de callbacks : un model() bidirectionnel remplace writeValue/registerOnChange.
  • État natif : disabled(), invalid(), errors() arrivent directement dans le composant via input(), plus besoin de @Input() manuels.
  • Moins de code : ~50 lignes → ~30 lignes, aucun provider à déclarer, aucune fonction callback à stocker.

Gain en testabilité

Avant

it('should require email', () => {
  const emailControl = component.form.get('email');
  emailControl?.setValue('');
  expect(emailControl?.valid).toBeFalse();
  expect(emailControl?.errors?.['required']).toBeTruthy();
});

Après

it('should require email', () => {
  component.f.email.setValue('');
  expect(component.f.email().invalid()).toBeTrue();
  expect(component.f.email().errors().some(e => e.kind === 'required')).toBeTrue();
});

Pas de ?. optionnel, accès direct, API synchrone et typée. Les tests sont plus concis et plus robustes.

Ce qu’il faut retenir

CritèreAvant (Reactive Forms / ngModel)Signal Forms (v22)
TypageFaible, any/Partial, pas de vérification compileFort, inféré du modèle
Accès aux champsform.get('name') ou #ref="ngModel" (chaînes, refs templates)f.name (propriétés, autocomplétion)
Validation croiséeSur le groupe, erreur génériqueSur le champ concerné, erreur localisée
Champs dynamiquesFormArray + casts, ou *ngFor + ngModel manuelTableau de signaux natif
AsyncValidateur RxJS, debounce manuel, pas de gestion native pour ngModelvalidateHttp / validateAsync avec debounce intégré
Messages d’erreurÀ gérer manuellement dans le templateEmbarqués dans les validateurs
Contrôle customControlValueAccessor + NG_VALUE_ACCESSOR + callbacksFormValueControl + model() + [formField]
TestsChaînes, optionnel chaining, change detection manuelleAccès direct, typé, pas de fixture.detectChanges()
BoilerplateÉlevé (FormGroup, FormBuilder, providers CVA, directives ngModel…)Minimal

Signal Forms ne se contentent pas d’être une nouvelle syntaxe : ils repensent la manière de concevoir les formulaires dans Angular. Moins de code, plus de sécurité, et une expérience développeur qui monte d’un cran. Avec le passage en stable dans Angular 22, c’est le moment de les adopter.