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({ résolveur : zodResolver(formSchema), valeurs par défaut : { prix : 0, quantité : 1, taux d'imposition : 0,1, satisfaction : 3, hasAccount : "Non", }, }); const price = useWatch({ control, name: "price" }); const quantité = useWatch({ contrôle, nom : "quantité" }); const taxRate = useWatch({ contrôle, nom : "taxRate" }); const hasAccount = useWatch({ contrôle, nom : "hasAccount" }); const satisfaction = useWatch({ contrôle, nom : "satisfaction" }); const sous-total = useMemo(() => (prix ?? 0) * (quantité ?? 1), [prix, quantité]); const tax = useMemo(() => sous-total * (taxRate ?? 0), [sous-total, taxRate]); const total = useMemo(() => sous-total + taxe, [sous-total, taxe]); const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total }); const showSubmit = (étape === 2 && total < 100) || (étape === 3 && total >= 100)

return (

{étape === 0 && ( <> )}

{step === 1 && ( <>

Sous-total : {subtotal}
Taxe : {tax}
Total : {total}
)}

{step === 2 && ( <>

{hasAccount === "Oui" && ( <> )}

{satisfaction >= 4 && (