Aller au contenu
Sylvain Joffraud l4w
Retour

React Hook Form : `trigger()` valide les étapes passées

Avec React Hook Form, trigger() sans argument valide tous les champs enregistrés du formulaire, y compris ceux des étapes déjà visitées d’un stepper. Dans le tunnel de souscription d’un SaaS d’abonnements (React Hook Form 7.48.2), revenir à la première étape pour changer le type d’abonnement bloquait le bouton « Suivant » : une règle de l’étape « Clients », démontée depuis longtemps, était encore évaluée. Ce bug est arrivé après deux autres sur le même bouton, entre décembre 2025 et mars 2026 : une validation lue au mauvais moment, puis des règles effacées par reset().

Premier bug : formState.isValid lu au mauvais moment

Le tunnel est un formulaire unique, partagé par un FormProvider, découpé en étapes : type, compte, clients, offre, prix et révision tarifaire, annexes, validation. Le composant Stepper gère « Suivant ». Version d’origine :

const { reset, formState: { isValid } } = useFormContext();

const handleNext = () => {
  (handleSave?.() || Promise.resolve()).then(() => {
    if (isValid) {
      navigate(isLastStep ? redirectUrl : baseUrl + (activeStep + 1));
    }
  });
};

Deux défauts : l’enregistrement part avant toute vérification, et isValid est la valeur capturée au dernier rendu, pas le résultat d’une validation faite au clic. Résultat remonté : un abonnement généré et validé sans aucun client.

Correctif : valider d’abord, avec le booléen renvoyé par trigger().

const handleNext = async () => {
  const isFormValid = await trigger();
  if (!isFormValid) return;
  try {
    await handleSave?.();
    navigate(isLastStep ? redirectUrl : baseUrl + (activeStep + 1));
  } catch {
    // handleSave peut rejeter pour bloquer la navigation (modale de confirmation)
  }
};

La fausse piste des « champs virtuels »

Trois semaines plus tard, une première validation par étape (trigger(fieldsToValidate)) ne bloquait plus l’absence de client sur un abonnement créé à partir d’un existant. Elle a été annulée au profit du trigger() global, avec ce commentaire :

// Note: trigger() sans arguments est nécessaire car les champs virtuels
// (customers, account, plan) enregistrés via register() sans élément DOM
// ne fonctionnent pas correctement avec trigger(['fieldName'])
const isValid = await trigger();

Un champ virtuel est ici un champ enregistré par register('customers', { validate }) sans input associé : la valeur est une liste de clients cochés, poussée par setValue. Le commentaire désignait le mauvais coupable. Les modales de création d’un client partageaient le formulaire du tunnel et appelaient reset() à l’ouverture et à la fermeture. Dans le code de React Hook Form 7.48.2, reset() sans keepValues vide le registre interne (_fields = {}) : la règle validate de customers disparaissait. Et trigger('customers') sur un nom absent du registre parcourt un objet vide et renvoie true.

Un premier correctif a remplacé reset() par une sauvegarde des valeurs (getValues()) puis une restauration champ par champ avec setValue, qui préserve les règles.

Pourquoi trigger() valide-t-il aussi les étapes passées ?

Le trigger() global, gardé « par sécurité », a produit le bug suivant. register() pose mount: true sur le champ. Pour un input réel, le callback ref repasse ce drapeau à false au démontage. Un champ virtuel n’a pas de ref : il reste monté pour toujours, et l’option shouldUnregister vaut false par défaut. trigger() sans argument appelle la validation sur tout le registre ; validateField n’ignore que les champs non montés ou désactivés.

Dans React Hook Form, un champ enregistré sans élément DOM ne se démonte jamais : trigger() sans argument continue de valider une étape que l’utilisateur a quittée.

Le scénario remonté : aller plus loin dans le tunnel, revenir à l’étape 1 pour changer le type d’abonnement, cliquer sur « Suivant » : refusé. Une règle d’une étape suivante, encore enregistrée et non satisfaite, bloquait l’étape 1.

Le correctif : une liste de champs par étape

Chaque étape déclare les champs qu’elle valide, et le stepper ne valide que ceux-là :

const FORM_STEPS = [
  renderStep(SubscriptionStep.ACCOUNT, <SubscriptionAccount />, ['account']),
  renderStep(SubscriptionStep.CUSTOMERS, <SubscriptionCustomers />, ['customers']),
  renderStep(SubscriptionStep.PLAN, <SubscriptionPlan />, ['plan']),
  renderStep(SubscriptionStep.PRICING, <SubscriptionPricing />, pricingStepFields),
];

const handleNext = async () => {
  const fieldsToValidate = steps[activeStep].fieldsToValidate;
  let isValid = true;
  if (fieldsToValidate?.length) {
    isValid = await trigger(fieldsToValidate);
  }
  if (!isValid) {
    scrollToFirstError();
    return;
  }
  // enregistrement puis navigation
};

Cette fois, trigger(['customers']) fonctionne, parce que plus rien n’efface le registre. Le test de non-régression reproduit le cas : le champ account de l’étape 2, enregistré avec sa règle validate et vide, stepper à l’étape 0 : la navigation doit passer.

Dernière étape, en mars 2026 : la modale de création d’un client a reçu son propre useForm, initialisé par defaultValues, au lieu d’emprunter le formulaire du tunnel. À l’enregistrement, le client créé est ajouté à la liste du tunnel :

setValue('customers', [...currentCustomers, customerIri], { shouldValidate: true });

Ces modales s’ouvrent dans les mêmes drawers que ceux de l’article sur les menus Select MUI vides dans un drawer.

Lessons learned

Questions fréquentes

Que valide <code>trigger()</code> sans argument dans React Hook Form ?
Dans React Hook Form 7, trigger() sans argument valide tous les champs présents dans le registre interne du formulaire, pas seulement ceux affichés. Un champ enregistré par register('nom', { validate }) sans élément DOM reste monté jusqu’à un unregister() ou un reset() : dans un stepper, ses règles s’appliquent donc encore quand l’utilisateur est revenu à une étape précédente.
Comment valider uniquement l’étape courante d’un formulaire multi-étapes React Hook Form ?
Il faut passer la liste des champs de l’étape à trigger(['plan', 'customers']), qui renvoie une promesse de booléen. Le plus simple est de déclarer cette liste dans la définition de chaque étape, à côté du composant, et de laisser le stepper l’utiliser au clic sur « Suivant ». Une étape sans champ obligatoire peut ne rien valider du tout.
Pourquoi <code>formState.isValid</code> ne suffit pas pour bloquer un bouton « Suivant » ?
formState.isValid est un état dérivé, mis à jour par abonnement et lu au rendu du composant. Dans un handler asynchrone, c’est la valeur capturée au dernier rendu, pas le résultat d’une validation faite au moment du clic. await trigger() lance la validation maintenant et renvoie le résultat : c’est ce booléen qui doit décider de la navigation et de l’enregistrement.
Pourquoi <code>reset()</code> fait-il disparaître les validations dans React Hook Form ?
Dans React Hook Form 7.48, reset() sans option keepValues vide le registre interne des champs. Les règles posées par register('nom', { validate }) disparaissent jusqu’au prochain appel de register. Ensuite, trigger('nom') sur un champ absent du registre renvoie true sans rien vérifier : la validation semble passer alors qu’elle n’existe plus.

Partager cet article :

Article précédent
MLX `Attempting to eval an array` : `.item()` en compilé
Article suivant
OpenWrt BusyBox : uci `Invalid argument` et 2 pièges SSH