~10 min de lecture

Validateurs async : 4 pièges qui spamment ton API et gèlent ton formulaire

TL;DR

Un validateur async naïf déclenche une requête HTTP par caractère tapé : rien ne debounce à ta place. Le correctif tient en une ligne (timer(300) + switchMap), et il marche parce qu'Angular annule la validation précédente à chaque nouvelle valeur. Trois autres pièges attendent derrière : un observable qui ne complète jamais fige le contrôle en PENDING pour toujours, form.invalid reste false pendant la validation (ton submit part trop tôt), et l'affichage du statut PENDING sous OnPush ne tient qu'à un effet de bord qui disparaît dès que le spinner déménage dans un composant enfant. Tout est mesuré sur Angular 22 ; l'essentiel date des débuts de Reactive Forms, sauf le piège 4, dont le mécanisme a basculé en 18.1.

Le formulaire qui mitraille ton propre backend

Le scénario classique : un champ email dans un formulaire d'inscription, et une règle métier « l'email ne doit pas déjà exister ». Impossible à vérifier côté client, donc tu écris ton premier validateur async :

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
  AbstractControl,
  NonNullableFormBuilder,
  ReactiveFormsModule,
  ValidationErrors,
  Validators,
} from '@angular/forms';
import { map, Observable } from 'rxjs';

@Component({
  selector: 'app-signup',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <input formControlName="email" type="email" />
      <button type="submit" [disabled]="form.invalid">Créer le compte</button>
    </form>
  `,
})
export default class SignupPage {
  private readonly http = inject(HttpClient);
  private readonly fb = inject(NonNullableFormBuilder);

  protected readonly form = this.fb.group({
    email: this.fb.control('', {
      validators: [Validators.required, Validators.email],
      asyncValidators: [(control) => this.emailAvailable(control)],
    }),
  });

  private emailAvailable(
    control: AbstractControl<string>,
  ): Observable<ValidationErrors | null> {
    return this.http
      .get<{ taken: boolean }>('/api/email-check', {
        params: { email: control.value },
      })
      .pipe(map(({ taken }) => (taken ? { emailTaken: true } : null)));
  }

  protected submit(): void {
    // ...
  }
}

Ça compile, ça marche, la démo passe. Puis tu ouvres l'onglet réseau et tu tapes jean.dupont@mail.fr. Résultat mesuré : six requêtes vers /api/email-check pour une seule adresse, une par caractère dès que la saisie passe Validators.email. Sur un backend qui limite le débit, tes utilisateurs se font jeter par leur propre formulaire d'inscription.

Avant de corriger, il faut comprendre ce qu'Angular fait, et surtout ce qu'il ne fait pas.

Ce qu'Angular fait vraiment à chaque frappe

À chaque changement de valeur, le contrôle relance sa validation : les validateurs synchrones d'abord, puis les asynchrones, mais seulement si les synchrones passent. C'est documenté, et c'est à exploiter : tant que la saisie ne passe pas tes validateurs sync, ton API n'est jamais appelée. Attention : c'est la paire required + email qui filtre, email seul retourne null sur la chaîne vide. Mets tes validateurs sync les plus stricts sur un champ qui porte un validateur async, ils servent de filtre gratuit.

Deuxième mécanique, moins connue : quand une nouvelle validation démarre, Angular se désabonne de la précédente encore en vol. Avec HttpClient, se désabonner annule la requête HTTP. Ton onglet réseau montre donc des requêtes canceled en cascade, et seule la dernière compte. C'est moins grave qu'une vraie race condition, mais chaque requête annulée a quand même touché ton serveur.

Nuance : cette annulation ne vaut que pour les observables. Une Promise ne s'annule pas ; Angular ignore son résultat obsolète (pas de course sur les erreurs affichées), mais la requête HTTP, elle, va au bout. Reste sur Observable pour tout validateur qui touche le réseau.

Piège 1 : aucun debounce intégré, et le correctif d'une ligne

Rien dans l'API des Reactive Forms ne debounce les validateurs async (les Signal Forms, elles, ont une règle debounce(), on en reparle en conclusion). Le correctif s'appuie sur l'annulation qu'on vient de voir : si Angular se désabonne de la validation précédente à chaque frappe, il suffit de faire commencer ton observable par une attente.

import { map, Observable, switchMap, timer } from 'rxjs';

private emailAvailable(
  control: AbstractControl<string>,
): Observable<ValidationErrors | null> {
  return timer(300).pipe(
    switchMap(() =>
      this.http.get<{ taken: boolean }>('/api/email-check', {
        params: { email: control.value },
      }),
    ),
    map(({ taken }) => (taken ? { emailTaken: true } : null)),
  );
}

Le déroulé : la validation démarre, timer(300) attend. Si l'utilisateur retape dans les 300 ms, Angular annule la validation en cours, donc le timer, et la requête ne part jamais. Ce n'est pas debounceTime qui fait le travail, c'est le cycle de vie du validateur. Sur la saisie de jean.dupont@mail.fr, tu passes de six requêtes à une seule. Pour tester ce debounce, les fake timers de Vitest font le travail.

Pour aller plus loin, updateOn: 'blur' repousse la mise à jour de la valeur et toute la validation à la sortie du champ :

email: this.fb.control('', {
  validators: [Validators.required, Validators.email],
  asyncValidators: [(control) => this.emailAvailable(control)],
  updateOn: 'blur',
}),

Au plus une validation par visite du champ (zéro si tu ressors sans avoir rien modifié), zéro requête pendant la frappe. Dans ce mode, le timer(300) du piège 1 devient superflu : plus de rafale à amortir, juste 300 ms de latence ajoutées à chaque sortie de champ. Le prix : plus aucun feedback en temps réel, et valueChanges ne bouge plus pendant la saisie, ce qui peut surprendre si autre chose écoute ce flux. C'est un curseur produit, pas une vérité technique.

Piège 2 : l'observable qui ne complète jamais

La règle que tout le monde découvre à ses dépens : l'observable retourné par un validateur async doit compléter. Angular compose tes validateurs async avec forkJoin, qui n'émet qu'à la complétion de toutes ses sources. HttpClient complète après une émission, donc le code ci-dessus est sain. Mais le jour où quelqu'un tente d'implémenter le debounce « à la RxJS » en branchant le validateur sur valueChanges, voilà ce que ça donne :

// NE FAIS PAS ÇA
private emailAvailable(
  control: AbstractControl<string>,
): Observable<ValidationErrors | null> {
  return control.valueChanges.pipe(
    debounceTime(300),
    switchMap((email) =>
      this.http.get<{ taken: boolean }>('/api/email-check', {
        params: { email },
      }),
    ),
    map(({ taken }) => (taken ? { emailTaken: true } : null)),
  );
}

Le défaut n'est pas celui qu'on soupçonne. Le timing, contre-intuitivement, tient la route : Angular émet valueChanges juste après avoir lancé la validation async, donc l'abonnement du validateur reçoit bien la valeur tapée, la requête part, et le résultat est même émis au bout du pipe. Le vrai problème est la complétion : valueChanges ne complète jamais, donc le forkJoin n'émet jamais, le résultat n'atteint pas le contrôle et le statut reste PENDING pour toujours. Un bug d'autant plus vicieux qu'il ne jette aucune erreur. Ce pattern circule sur Stack Overflow depuis des années ; si tu le croises en revue de code, c'est non.

Le symptôme à retenir : un contrôle coincé en PENDING sans requête en vol a deux coupables possibles. Le premier : un observable de validation qui ne complète pas. Le second t'attend dans la section sur les erreurs HTTP.

Piège 3 : PENDING n'est ni valide ni invalide

Pendant qu'une validation async tourne, le statut du contrôle (et du FormGroup qui le contient) vaut 'PENDING'. Et là, une subtilité de l'API fait très mal :

control.status; // 'PENDING'
control.pending; // true
control.valid; // false : valid === (status === 'VALID')
control.invalid; // false : invalid === (status === 'INVALID')

valid et invalid sont tous les deux faux. Ce ne sont pas des opposés, ce sont deux comparaisons de statut distinctes. Relis le template du début :

<button type="submit" [disabled]="form.invalid">Créer le compte</button>

Pendant les 300 ms de debounce plus le temps de la requête, form.invalid vaut false, donc le bouton est cliquable. L'utilisateur rapide soumet un email dont la vérification n'est pas terminée. Selon ce que fait ton submit(), tu crées un compte en double ou tu envoies un payload que le backend refusera.

Le correctif est trivial une fois le piège compris :

<button type="submit" [disabled]="!form.valid">Créer le compte</button>

!form.valid couvre INVALID, PENDING et le quatrième statut qu'on oublie toujours, DISABLED (un form.disable() pendant la soumission, par exemple). L'écriture « explicite » form.invalid || form.pending n'est donc pas équivalente : elle laisse le bouton cliquable sur un formulaire désactivé. Et par symétrie, méfie-toi des affichages d'erreur conditionnés sur invalid : pendant la validation, le champ n'est pas invalide, il est indéterminé.

Piège 4 : ton spinner PENDING ne tient qu'à un fil

Tu veux afficher « Vérification... » pendant la requête. Premier réflexe :

@if (form.controls.email.pending) {
  <span>Vérification...</span>
}

Sur Angular 18.1+, ce template précis marche. Mais pas pour la raison que tu crois. Lire pending ne crée aucune dépendance réactive : depuis Angular 18.1, AbstractControl tient son statut dans des signaux internes, mais le getter status le lit en untracked. Ce qui sauve ton spinner ici, ce sont les directives posées à côté : formControlName et [formGroup] installent NgControlStatus / NgControlStatusGroup, dont les host bindings (les classes ng-pending et compagnie) lisent, eux, un computed tracké. Chaque changement de statut marque donc la vue pour rafraîchissement, et ton @if est réévalué par effet de bord.

Déplace le spinner dans un composant enfant OnPush qui reçoit le contrôle en input, sans directive de formulaire dans son propre template, et l'effet de bord disparaît : la vue enfant n'est jamais rafraîchie, donc le spinner ne s'affiche même pas. Variante : un contrôle piloté par setValue(), sans aucune directive de formulaire dans la vue ; là, le spinner apparaît puis reste figé en PENDING. Et avant Angular 18.1, même le template ci-dessus restait figé après la fin de la validation.

Un affichage qui repose sur la présence accidentelle d'une directive dans la même vue casse au premier refactoring. La sortie propre : matérialiser le statut en signal via statusChanges, pour que la dépendance soit la tienne :

import { toSignal } from '@angular/core/rxjs-interop';
import { computed } from '@angular/core';

export default class SignupPage {
  // ... form comme plus haut

  private readonly emailStatus = toSignal(
    this.form.controls.email.statusChanges,
    { initialValue: this.form.controls.email.status },
  );

  protected readonly checkingEmail = computed(
    () => this.emailStatus() === 'PENDING',
  );
}
@if (checkingEmail()) {
  <span>Vérification...</span>
}

toSignal (introduit en v16 en developer preview, stable depuis Angular 20) s'abonne à statusChanges, et la lecture du signal dans le template marque le composant comme à revérifier à chaque transition PENDING -> VALID / INVALID. Ton spinner ne dépend plus de qui d'autre habite la vue, sous OnPush comme en zoneless.

Et quand ton API tombe ?

Dernier point, à trancher explicitement : si /api/email-check répond 500, l'observable du validateur émet une erreur, et le contrôle passe... à ton avis ? Réponse mesurée : le contrôle reste PENDING définitivement, errors reste à null, et l'erreur part en unhandled error RxJS (Angular ne s'abonne qu'au next), le tout rejoué à chaque frappe. C'est le deuxième coupable annoncé au piège 2 : un champ coincé en PENDING sans aucune requête en vol. Gère le cas avec catchError, et décide d'une politique :

import { catchError, of } from 'rxjs';

// fail open : API down = on laisse passer, le backend tranchera au submit
catchError(() => of(null)),

// fail closed : API down = champ en erreur, l'utilisateur est bloqué
catchError(() => of({ emailCheckFailed: true })),

Pour une vérification de disponibilité, fail open est presque toujours le bon choix : le backend fait de toute façon la vérification finale à l'inscription (s'il ne la fait pas, ta seule barrière est un contrôle client contournable, ce qui n'est pas une validation). Bloquer toutes les inscriptions parce qu'un endpoint de confort est tombé transforme une panne mineure en incident.

Before / after

Le validateur naïf du début :

  • une requête HTTP par caractère tapé dès que le format est valide ;
  • un bouton submit cliquable pendant la vérification ;
  • un spinner dont la mise à jour repose sur un effet de bord des directives de la vue ;
  • une API en 500 qui fige le champ en PENDING sans la moindre erreur de validation.

(Le piège 2 manque à l'appel : il n'apparaît qu'en tentant de corriger le 1 ; le validateur naïf, lui, complète tout seul via HttpClient.)

La version corrigée :

private emailAvailable(
  control: AbstractControl<string>,
): Observable<ValidationErrors | null> {
  return timer(300).pipe(
    switchMap(() =>
      this.http.get<{ taken: boolean }>('/api/email-check', {
        params: { email: control.value },
      }),
    ),
    map(({ taken }) => (taken ? { emailTaken: true } : null)),
    catchError(() => of(null)),
  );
}
  • une requête par pause de frappe, les autres annulées avant de partir ;
  • [disabled]="!form.valid" : pas de submit pendant PENDING ;
  • un statut matérialisé en signal, fiable quelle que soit la stratégie de détection ;
  • une politique d'erreur explicite.

Récap actionnable

  1. Sync d'abord, toujours. Les validateurs async ne tournent que si les sync passent : required et email sont ton premier étage de filtrage anti-spam.
  2. timer(300) + switchMap en tête de validateur. Le debounce vient de l'annulation par Angular de la validation précédente, pas d'un debounceTime.
  3. Retourne des observables, pas des promesses, pour que l'annulation coupe aussi la requête HTTP.
  4. L'observable doit compléter. HttpClient complète tout seul ; valueChanges dans un validateur est un anti-pattern qui fige le statut en PENDING.
  5. invalid !== !valid. Désactive ton submit avec !form.valid, jamais avec form.invalid, sinon il est cliquable pendant la validation (et !form.valid couvre aussi DISABLED).
  6. Matérialise statusChanges en signal avec toSignal : l'affichage de PENDING ne doit pas dépendre d'une directive de formulaire présente par hasard dans la même vue.
  7. catchError obligatoire, avec une politique choisie : fail open pour le confort, fail closed pour le critique. Sans lui, un 500 fige le champ en PENDING.
  8. updateOn: 'blur' si le temps réel n'apporte rien : au plus une validation par visite du champ, zéro requête pendant la frappe, et le timer(300) devient superflu.

Tout ça vaut pour les Reactive Forms, d'Angular 14 à 22, à une exception près : le mécanisme du piège 4 a basculé en 18.1, quand AbstractControl est passé aux signaux en interne. Les Signal Forms, stabilisées en Angular 22, rebattent les cartes : elles apportent d'ailleurs une règle debounce() intégrée, celle qui manque aux Reactive Forms. Mais vu le parc de formulaires Reactive en production, ces quatre pièges ont encore de longues années devant eux.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.