Este artigo é patrocinado pela SurveyJS Existe um modelo mental que a maioria dos desenvolvedores do React compartilha sem nunca discuti-lo em voz alta. Que os formulários sempre devem ser componentes. Isso significa uma pilha como:

Formulário React Hook para estado local (re-renderizações mínimas, registro de campo ergonômico, interação imperativa). Zod para validação (correção de entrada, validação de limite, análise de tipo seguro). React Query para back-end: envio, novas tentativas, cache, sincronização de servidor e assim por diante.

E para a grande maioria dos formulários – suas telas de login, suas páginas de configurações, seus modais CRUD – isso funciona muito bem. Cada peça faz seu trabalho, elas são compostas de forma limpa e você pode passar para as partes de sua aplicação que realmente diferenciam seu produto. Mas de vez em quando, um formulário começa a acumular coisas como regras de visibilidade que dependem de respostas anteriores ou valores derivados que se espalham por três campos. Talvez até páginas inteiras que deveriam ser ignoradas ou mostradas com base em um total acumulado. Você lida com a primeira condicional com um useWatch e uma ramificação embutida, o que é bom. Depois outro. Então você está recorrendo ao superRefine para codificar regras de campo cruzado que seu esquema Zod não pode expressar da maneira normal. Então, a navegação por etapas começa a vazar a lógica de negócios. Em algum momento, você olha o que construiu e percebe que o formulário não é mais uma UI. É mais um processo de decisão, e a árvore de componentes é exatamente onde você os armazenou. É aqui que eu acho que o modelo mental para formulários no React falha, e na verdade não é culpa de ninguém. A pilha RHF + Zod é excelente para o que foi projetada. A questão é que tendemos a continuar a usá-lo para além do ponto em que as suas abstrações correspondem ao problema, porque a alternativa requer uma forma totalmente diferente de pensar sobre as formas. Este artigo é sobre essa alternativa. Para mostrar isso, construiremos exatamente o mesmo formulário de várias etapas duas vezes:

Com React Hook Form + Zod conectado ao React Query para envio, Com SurveyJS, que trata um formulário como dados — um esquema JSON simples — em vez de uma árvore de componentes.

Os mesmos requisitos, a mesma lógica condicional, a mesma chamada de API no final. Em seguida, mapearemos exatamente o que mudou e o que permaneceu e apresentaremos uma maneira prática de decidir qual modelo você deve usar e quando. O formulário que estamos construindo:

Este formulário usará um fluxo de 4 etapas: Etapa 1: detalhes

Nome (obrigatório), E-mail (obrigatório, formato válido).

Etapa 2: pedido

Preço unitário, Quantidade, Taxa de imposto, Derivado: Subtotal, Imposto, Total.

Etapa 3: conta e feedback

Você tem uma conta? (Sim/Não) Se sim → nome de usuário + senha, ambos obrigatórios. Se Não → e-mail já coletado na etapa 1.

Classificação de satisfação (1–5) Se ≥ 4 → pergunte “Do que você gostou?” Se ≤ 2 → pergunte “O que podemos melhorar?”

Etapa 4: revisão

Só aparece se total >= 100 Submissão final.

Isto não é extremo. Mas é o suficiente para expor diferenças arquitetônicas. Parte 1: Orientado por Componente (Formulário React Hook + Zod) Instalação npm install react-hook-form zod @hookform/resolvers @tanstack/react-query

Esquema Zod Vamos começar com o esquema Zod, porque geralmente é aí que a forma do formulário é estabelecida. Nas duas primeiras etapas – dados pessoais e entradas de pedidos – tudo é simples: strings obrigatórias, números com mínimos e uma enumeração. A parte interessante começa quando você tenta expressar as regras condicionais.

importar {z} de "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Required"), email: z.string().email("E-mail inválido"), preço: z.number().min(0), quantidade: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Sim", "Não"]), nome de usuário: z.string().opcional(), senha: z.string().optional(), satisfação: z.number().min(1).max(5), feedback positivo: z.string().optional(), melhoriaFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Sim") { if (!data.username) { ctx.addIssue({ código: "custom", caminho: ["nome de usuário"], mensagem: "Obrigatório" } } if (!data.password || data.password.length < 6) { ctx.addIssue({ código: "custom", caminho: ["senha"], mensagem: "Mínimo 6 caracteres" });

if (data.satisfaction >= 4 && !data.positivoFeedback) { ctx.addIssue({ código: "custom", caminho: ["positivoFeedback"], mensagem: "Por favor, compartilhe o que você gostou" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ código: "custom", caminho:["improvementFeedback"], mensagem: "Diga-nos o que melhorar" }); }});

tipo de exportação FormData = z.infer;

Observe que o nome de usuário e a senha são digitados como opcional() embora sejam condicionalmente obrigatórios porque o esquema de nível de tipo do Zod descreve a forma do objeto, não as regras que regem quando os campos são importantes. O requisito condicional deve estar dentro do superRefine, que é executado após a validação da forma e tem acesso ao objeto completo. Essa separação não é uma falha; é exatamente para isso que a ferramenta foi projetada: superRefine é para onde vai a lógica de campo cruzado quando não pode ser expressa na própria estrutura do esquema. O que também é notável aqui é o que esse esquema não expressa. Não tem conceito de páginas, nenhum conceito de quais campos estão visíveis e em que ponto e nenhum conceito de navegação. Tudo isso viverá em outro lugar. Componente de formulário

importar { useForm, useWatch } de "react-hook-form"; importar { zodResolver } de "@hookform/resolvers/zod"; importar { useMutation } de "@tanstack/react-query"; importar { useState, useMemo } de "react";

const STEPS = ["detalhes", "pedido", "conta", "revisão"];

digite OrderPayload = FormData & { subtotal: número; imposto: número; total: número };

função de exportação RHFMultiStepForm() { const [step, setStep] = useState(0);

mutação const = useMutation({ mutaçãoFn: assíncrono (carga útil: OrderPayload) => { const res = aguarda fetch("/api/orders", { método: "POSTAR", cabeçalhos: { "Content-Type": "application/json" }, corpo: JSON.stringify (carga útil), }); if (!res.ok) throw new Error("Falha ao enviar"); retornar res.json(); }, });

const {registro, controle, handleSubmit, formState: { erros }, } = useForm({ resolvedor: zodResolver(formSchema), defaultValues: { preço: 0, quantidade: 1, taxRate: 0,1, satisfação: 3, hasAccount: "Não", }, }); const preço = useWatch({ controle, nome: "preço" }); const quantidade = useWatch({ controle, nome: "quantidade" }); const taxRate = useWatch({ controle, nome: "taxRate" }); const hasAccount = useWatch({ controle, nome: "hasAccount" }); const satisfação = useWatch({ controle, nome: "satisfação" }); const subtotal = useMemo(() => (preço ?? 0) * (quantidade ?? 1), [preço, quantidade]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + imposto, [subtotal, imposto]); const onSubmit = (dados: FormData) =>mutation.mutate({ ...dados, subtotal, imposto, total }); const showSubmit = (etapa === 2 && total <100) || (etapa === 3 && total >= 100)

return (

{step === 0 && ( <> )}

{step === 1 && ( <>

Subtotal: {subtotal}
Imposto: {tax}
Total: {total}
)}

{step === 2 && ( <>

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

{satisfação >= 4 && (