Cet article est sponsorisé par SurveyJS Il existe un modèle mental que la plupart des développeurs React partagent sans jamais en discuter à voix haute. Ces formulaires sont toujours censés être des composants. Cela signifie une pile comme :
React Hook Form pour l'état local (re-rendus minimaux, enregistrement ergonomique sur le terrain, interaction impérative). Zod pour la validation (exactitude de la saisie, validation des limites, analyse de type sécurisé). React Query pour le backend : soumission, tentatives, mise en cache, synchronisation du serveur, etc.
Et pour la grande majorité des formulaires – vos écrans de connexion, vos pages de paramètres, vos modaux CRUD – cela fonctionne très bien. Chaque pièce fait son travail, elle est composée proprement et vous pouvez passer aux parties de votre application qui différencient réellement votre produit. Mais de temps en temps, un formulaire commence à accumuler des éléments tels que des règles de visibilité qui dépendent de réponses antérieures ou des valeurs dérivées qui se répercutent sur trois champs. Peut-être même des pages entières qui doivent être ignorées ou affichées en fonction d'un total cumulé. Vous gérez la première condition avec un useWatch et une branche en ligne, ce qui est très bien. Puis un autre. Ensuite, vous utilisez superRefine pour encoder des règles inter-champs que votre schéma Zod ne peut pas exprimer de la manière normale. Ensuite, la navigation par étapes commence à fuir la logique métier. À un moment donné, vous regardez ce que vous avez construit et réalisez que le formulaire n’est plus vraiment une interface utilisateur. Il s’agit plutôt d’un processus de décision, et l’arborescence des composants est exactement l’endroit où vous l’avez stocké. C’est là que je pense que le modèle mental des formulaires dans React s’effondre, et ce n’est vraiment la faute de personne. La pile RHF + Zod est excellente dans ce pour quoi elle a été conçue. Le problème est que nous avons tendance à continuer à l’utiliser au-delà du point où ses abstractions correspondent au problème, car l’alternative nécessite une manière entièrement différente de penser les formes. Cet article concerne cette alternative. Pour le montrer, nous allons créer deux fois exactement le même formulaire en plusieurs étapes :
Avec React Hook Form + Zod connectés à React Query pour la soumission, Avec SurveyJS, qui traite un formulaire comme des données – un simple schéma JSON – plutôt que comme une arborescence de composants.
Mêmes exigences, même logique conditionnelle, même appel API à la fin. Ensuite, nous cartographierons exactement ce qui a bougé et ce qui est resté, et proposerons un moyen pratique de décider quel modèle vous devez utiliser et quand. Le formulaire que nous construisons :
Ce formulaire utilisera un flux en 4 étapes : Étape 1 : Détails
Prénom (obligatoire), Courriel (obligatoire, format valide).
Étape 2 : Commande
Prix unitaire, Quantité, Taux d'imposition, Dérivé : Sous-total, Taxe, Totale.
Étape 3 : Compte et commentaires
Avez-vous un compte ? (Oui/Non) Si oui → nom d'utilisateur + mot de passe, les deux sont obligatoires. Si Non → email déjà collecté à l’étape 1.
Note de satisfaction (1 à 5) Si ≥ 4 → demander « Qu’est-ce que tu as aimé ? » Si ≤ 2 → demander « Que pouvons-nous améliorer ? »
Étape 4 : Révision
Apparaît uniquement si total >= 100 Soumission finale.
Ce n’est pas extrême. Mais cela suffit à exposer les différences architecturales. Partie 1 : Piloté par les composants (React Hook Form + Zod) Mise en place npm install réagissez-hook-form zod @hookform/resolvers @tanstack/react-query
Schéma Zod Commençons par le schéma Zod, car c'est généralement là que la forme du formulaire est établie. Pour les deux premières étapes (informations personnelles et saisie des commandes), tout est simple : les chaînes obligatoires, les nombres avec des minimums et une énumération. La partie intéressante commence lorsque vous essayez d’exprimer les règles conditionnelles.
importer { z } depuis "zod" ;
export const formSchema = z.object({ firstName : z.string().min(1, "Required"), email : z.string().email("Invalid email"), prix : z.number().min(0), quantité : z.number().min(1), taxRate : z.number(), hasAccount : z.enum(["Yes", "No"]), nom d'utilisateur : z.string().optional(), mot de passe : z.string().optional(), satisfaction : z.number().min(1).max(5), positiveFeedback : z.string().optional(), enhancementFeedback : z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ code: "custom", chemin : ["nom d'utilisateur"], message : "Obligatoire" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ code : "custom", chemin : ["mot de passe"], message : "Min 6 caractères" } } })
if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ code : "custom", chemin : ["positiveFeedback"], message : "Veuillez partager ce que vous avez aimé" }); }
if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ code : "custom", chemin :["improvementFeedback"], message : "Veuillez nous dire ce qu'il faut améliorer" }); }});
type d'exportation FormData = z.infer
Notez que le nom d'utilisateur et le mot de passe sont saisis comme optionnel() même s'ils sont requis sous condition, car le schéma au niveau du type de Zod décrit la forme de l'objet, et non les règles régissant l'importance des champs. L'exigence conditionnelle doit résider dans superRefine, qui s'exécute une fois la forme validée et a accès à l'objet complet. Cette séparation n’est pas un défaut ; c’est exactement pour cela que l’outil est conçu : superRefine est l’endroit où va la logique inter-champs lorsqu’elle ne peut pas être exprimée dans la structure du schéma elle-même. Ce qui est également remarquable ici, c’est ce que ce schéma n’exprime pas. Il n'a aucune notion de pages, aucune notion de champs visibles à quel moment, et aucune notion de navigation. Tout cela vivra ailleurs. Composant de formulaire
import { useForm, useWatch } depuis "react-hook-form"; import { zodResolver } depuis "@hookform/resolvers/zod"; import { useMutation } depuis "@tanstack/react-query"; import { useState, useMemo } depuis "react";
const STEPS = ["détails", "commande", "compte", "révision"];
tapez OrderPayload = FormData & { sous-total : nombre ; taxe : numéro ; total : nombre } ;
fonction d'exportation RHFMultiStepForm() { const [étape, setStep] = useState(0);
const mutation = useMutation({ mutationFn : async (charge utile : OrderPayload) => { const res = wait fetch("/api/orders", { méthode : "POST", en-têtes : { "Content-Type": "application/json" }, corps : JSON.stringify(charge utile), }); if (!res.ok) throw new Error("Échec de la soumission"); return res.json(); }, });
const { registre, contrôle, handleSubmit, formState : { erreurs }, } = useForm
return (
);}Voir le Pen SurveyJS-03-RHF [forked] par sixièmeextinction. Il se passe beaucoup de choses ici, et cela vaut la peine de ralentir pour remarquer où les choses ont abouti.
Les valeurs dérivées (sous-total, taxe, total) sont calculées dans le composant via useWatch et useMemo car elles dépendent des valeurs de champ en direct et il n'y a pas d'autre endroit naturel pour elles. Les règles de visibilité pour le nom d'utilisateur, le mot de passe, le positifFeedback et l'améliorationFeedback sont présentes dans JSX sous forme de conditions en ligne. La logique de saut d'étape (la page de révision n'apparaissant que lorsque le total >= 100) est intégrée dans la variable showSubmit et la condition de rendu à l'étape 3. La navigation elle-même n'est qu'un compteur useState que nous incrémentons manuellement. React Query gère les tentatives, la mise en cache et l'invalidation. Le formulaire appelle simplement mutation.mutate avec des données validées.
Rien de tout cela n’est faux en soi. Il s'agit toujours de React idiomatique, et le composant est assez performant grâce à la façon dont RHF isole les rendus. Mais si vous deviez remettre ceci à quelqu'un qui ne l'a pas écrit et lui demander d'expliquer dans quelles conditions la page de révision apparaît, il lui faudrait retracer showSubmit, la condition de rendu de l'étape 3 et la logique du bouton de navigation - trois endroits distincts - pour reconstruire une règle qui aurait pu être énoncée sur une seule ligne. Le formulaire fonctionne, oui, mais le comportement n’est pas vraiment inspectable en tant que système. Il faut l’exécuter mentalement. Plus important encore, sa modification nécessite l’implication de l’ingénierie. Même un petit ajustement, comme ajuster le moment où l'étape de révision apparaît, signifie modifier le composant, mettre à jour la validation, ouvrir une pull request, attendre la révision et déployer à nouveau. Partie 2 : Piloté par un schéma (SurveyJS) Créons maintenant le même flux à l'aide d'un schéma. Mise en place npm installe Survey-Core Survey-react-ui @tanstack/react-query
Survey-coreLe moteur d'exécution indépendant de la plate-forme sous licence MIT qui alimente le rendu des formulaires de SurveyJS - la partie qui nous intéresse ici. Il prend un schéma JSON, construit un modèle interne à partir de celui-ci et gère tout ce qui vivrait autrement dans votre composant React : évaluer les expressions de visibilité, calculer les valeurs dérivées, gérer l'état de la page, suivre la validation et décider de ce que signifie « complet » étant donné quelles pages ont été réellement affichées.
Survey-react-uiLa couche d'interface utilisateur/de rendu qui connecte ce modèle à React. Il s'agit essentiellement d'un composant
Ensemble, ils vous offrent un environnement d'exécution de formulaire multipage entièrement fonctionnel sans écrire une seule ligne de flux de contrôle. Le format du schéma lui-même est, comme indiqué précédemment, simplement un JSON – pas de DSL ni quoi que ce soit de propriétaire. Vous pouvez l'intégrer, l'importer à partir d'un fichier, le récupérer à partir d'une API ou le stocker dans une colonne de base de données et l'hydrater au moment de l'exécution. La même forme que les données Voici le même formulaire, cette fois exprimé sous forme d'objet JSON. Le schéma définit tout : structure, validation, règles de visibilité, calculs dérivés, navigation dans les pages – et le transmet à un modèle qui l'évalue au moment de l'exécution. Voici à quoi cela ressemble dans son intégralité :
export const SurveySchema = { titre : "Order Flow", showProgressBar : "top", pages : [ { nom : "détails", éléments : [ { type : "texte", nom : "firstName", isRequired : true }, { type : "texte", nom : "email", inputType : "email", isRequired : true, validateurs : [{ type : "email", texte : "E-mail invalide" }] } ] }, { nom : "commande", éléments : [ { type : "texte", nom : "prix", inputType : "numéro", valeur par défaut : 0 }, { type : "texte", nom : "quantité", inputType : "numéro", valeur par défaut : 1 }, { type : "liste déroulante",nom : "taxRate", valeur par défaut : 0,1, choix : [ { valeur : 0,05, texte : "5%" }, { valeur : 0,1, texte : "10%" }, { valeur : 0,15, texte : "15%" } ] }, { type : "expression", nom : "sous-total", expression : "{prix} {quantité}" }, { type : "expression", nom : "taxe", expression : "{sous-total} {taxRate}" }, { type : "expression", nom : "total", expression : "{sous-total} + {taxe}" } ] }, { nom : "compte", éléments : [ { type : "radiogroup", nom : "hasAccount", choix : ["Oui", "Non"] }, { type : "texte", nom : "nom d'utilisateur", visibleIf : "{hasAccount} = 'Oui'", isRequired : true }, { type : "text", nom : "password", inputType : "password", visibleIf : "{hasAccount} = 'Yes'", isRequired : true, validateurs : [{ type : "text", minLength : 6, text : "Min 6 caractères" }] }, { type : "note", nom : "satisfaction", rateMin : 1, rateMax : 5 }, { type : "commentaire", nom : "positiveFeedback", visibleIf : "{satisfaction} >= 4" }, { type : "commentaire", nom : "improvementFeedback", visibleIf : "{satisfaction} <= 2" } ] }, { nom : "review", visibleIf : "{total} >= 100", éléments : [] } ]} ;
Comparez cela à la version RHF pendant un instant.
Le bloc superRefine qui exigeait conditionnellement un nom d’utilisateur et un mot de passe a disparu. visibleIf : "{hasAccount} = 'Yes'" combiné avec isRequired: true gère les deux problèmes ensemble, sur le champ lui-même, là où vous vous attendez à les trouver. La chaîne useWatch + useMemo qui calcule le sous-total, la taxe et le total est remplacée par trois champs d'expression qui se référencent mutuellement par leur nom. La condition de la page de révision, qui dans la version RHF était reconstructible uniquement en traçant via showSubmit, la branche de rendu de l'étape 3. Et enfin, la logique du bouton de navigation est une seule propriété visibleIf sur l'objet page.
La même logique est là. C’est juste que le schéma lui donne un lieu de vie où il est visible de manière isolée, plutôt que dispersé à travers le composant. Notez également que le schéma utilise le type : « expression » pour le sous-total, la taxe et le total. L'expression est en lecture seule et est principalement utilisée pour afficher des valeurs calculées. SurveyJS prend également en charge le type : « html » pour le contenu statique, mais pour les valeurs calculées, l'expression est le bon choix. Passons maintenant au côté React. Rendu et soumission Très simple. Connectez onComplete à votre API de la même manière – via useMutation ou plain fetch :
importer { useState, useEffect, useRef } depuis "react"; importer { useMutation } depuis "@tanstack/react-query"; importer { Model } depuis "survey-core"; importer { Survey } depuis "survey-react-ui"; importer "survey-core/survey-core.css";
fonction d'exportation SurveyForm() { const [model] = useState(() => new Model(surveySchema));
const mutation = useMutation({ mutationFn : async (données) => { const res = wait fetch("/api/orders", { méthode : "POST", en-têtes : { "Content-Type": "application/json" }, corps : JSON.stringify(données), }); if (!res.ok) throw new Error("Échec de la soumission"); return res.json(); }, });
const mutationRef = useRef(mutation); mutationRef.current = mutation ; useEffect(() => { const handler = (sender) => mutationRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [model]); // ref évite de réenregistrer le gestionnaire à chaque rendu (changements d'identité de l'objet de mutation)
retour (
<>
Voir le Pen SurveyJS-03-SurveyJS [forked] par sixièmeextinction.
onComplete se déclenche lorsque l'utilisateur atteint la fin de la dernière page visible. Ainsi, si le total ne dépasse jamais 100 et que la page de révision est ignorée, elle se déclenche toujours correctement car SurveyJS évalue la visibilité avant de décider ce que signifie « dernière page ». Ensuite, sender.data contient toutes les réponses ainsi que les valeurs calculées (sous-total, taxe, total) en tant que champs de première classe, de sorte que la charge utile de l'API est identique à celle de la version RHF assemblée manuellement dans onSubmit. LeLe modèle mutationRef est le même que celui que vous utiliseriez partout où vous avez besoin d'un gestionnaire d'événements stable sur une valeur qui change à chaque rendu - rien de spécifique à SurveyJS à ce sujet.
Le composant React ne contient plus aucune logique métier. Il n'y a pas de useWatch, pas de JSX conditionnel, pas de compteur de pas, pas de chaîne useMemo, pas de superRefine. React fait ce pour quoi il est vraiment bon : restituer un composant et le connecter à un appel API. Qu’est-ce qui est sorti de React ?
Préoccupation Pile RHF EnquêteJS Visibilité Branches JSX visibleSi Valeurs dérivées useWatch / useMemo expressions Règles inter-champs superAffiner Conditions du schéma Navigation état d'étape Page visibleSi Emplacement de la règle Distribué dans les fichiers Centralisé dans le schéma
Ce qui reste dans React, c'est la mise en page, le style, le câblage de soumission et l'intégration d'applications, c'est-à-dire les choses pour lesquelles React est réellement conçu. Tout le reste a été transféré dans le schéma, et comme le schéma n'est qu'un objet JSON, il peut être stocké dans une base de données, versionné indépendamment du code de votre application ou modifié via des outils internes sans nécessiter de déploiement. Un chef de produit qui doit modifier le seuil qui déclenche la page de révision peut le faire sans toucher au composant. Il s’agit d’une différence opérationnelle significative pour les équipes où le comportement des formulaires évolue fréquemment et n’est pas toujours piloté par les ingénieurs. Quand utiliser chaque approche ? Voici une bonne règle de base qui fonctionne pour moi : imaginez supprimer entièrement le formulaire. Que perdriez-vous ?
S'il s'agit d'écrans, vous voulez des formulaires pilotés par des composants. S’il s’agit de logique métier, comme des seuils, des règles de branchement et des exigences conditionnelles qui codent des décisions réelles, vous avez besoin d’un moteur de schéma.
De même, si les changements à venir concernent principalement les étiquettes, les champs et la mise en page, RHF vous sera très utile. S'il s'agit de conditions, de résultats et de règles que vos opérations ou votre équipe juridique pourraient avoir besoin d'ajuster un mardi après-midi sans déposer de ticket, le modèle de schéma avec SurveyJS est la solution la plus honnête. Ces deux approches ne sont pas vraiment en concurrence. Ils abordent différentes classes de problèmes, et l’erreur à éviter est de ne pas faire correspondre l’abstraction au poids de la logique – traiter un système de règles comme un composant parce que c’est l’outil familier, ou recourir à un moteur de politique parce qu’un formulaire est passé à trois étapes et a acquis un champ conditionnel. Le formulaire que nous avons construit ici se situe délibérément près de la frontière, suffisamment complexe pour exposer la différence, mais pas si extrême que la comparaison semble truquée. La plupart des formulaires réels devenus lourds dans votre base de code se situent probablement à proximité de cette même limite, et la question est généralement simplement de savoir si quelqu'un a nommé ce qu'ils sont réellement. Utilisez React Hook Form + Zod lorsque :
Les formulaires sont orientés CRUD ; La logique est superficielle et basée sur l'interface utilisateur ; Les ingénieurs sont propriétaires de tous les comportements ; Le backend reste la source de vérité.
Utilisez SurveyJS lorsque :
Les formulaires codent les décisions commerciales ; Les règles évoluent indépendamment de l'interface utilisateur ; La logique doit être visible, auditable ou versionnée ; Les non-ingénieurs influencent le comportement ; Le même formulaire doit s’exécuter sur plusieurs frontends.