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
trigger()sans argument valide tout le registre, pas l’écran. Dans un stepper, il faut passer àtrigger()la liste des champs de l’étape courante.- Un champ enregistré sans élément DOM ne se démonte jamais. Sa règle
validatereste active tant qu’on n’appelle pasunregister(). formState.isValidn’est pas une validation. Pour décider d’un enregistrement ou d’une navigation, il faut le booléen renvoyé parawait trigger().reset()efface les règles en même temps que les valeurs. Ettrigger()sur un champ absent du registre renvoietruesans rien vérifier.- Une modale de création mérite son propre
useForm. Partager le formulaire d’un tunnel oblige à le réinitialiser, avec les effets de bord ci-dessus.