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
return (
);}Veja o Pen SurveyJS-03-RHF [bifurcado] da sexta extinção. Há muita coisa acontecendo aqui e vale a pena desacelerar para perceber onde as coisas foram parar.
Os valores derivados — subtotal, imposto, total — são calculados no componente por meio de useWatch e useMemo porque dependem de valores de campo ativos e não há outro local natural para eles. As regras de visibilidade para nome de usuário, senha, PositiveFeedback e ImprovementFeedback residem em JSX como condicionais embutidas. A lógica de salto de etapa — a página de revisão aparece apenas quando o total >= 100 — é incorporada na variável showSubmit e na condição de renderização na etapa 3. A navegação em si é apenas um contador useState que estamos incrementando manualmente. React Query lida com novas tentativas, armazenamento em cache e invalidação. O formulário apenas chamamutation.mutate com dados validados.
Nada disso está errado, por si só. Este ainda é um React idiomático, e o componente tem bastante desempenho graças à forma como o RHF isola as re-renderizações. Mas se você entregasse isso a alguém que não o escreveu e pedisse que explicasse sob quais condições a página de revisão aparece, ele teria que rastrear através de showSubmit, a condição de renderização da etapa 3 e a lógica do botão de navegação – três locais separados – para reconstruir uma regra que poderia ter sido declarada em uma linha. O formulário funciona, sim, mas o comportamento não é realmente inspecionável como sistema. Tem que ser executado mentalmente. Mais importante ainda, alterá-lo requer o envolvimento da engenharia. Mesmo um pequeno ajuste, como ajustar quando a etapa de revisão aparece, significa editar o componente, atualizar a validação, abrir uma solicitação pull, aguardar a revisão e implantar novamente. Parte 2: Orientado por esquema (SurveyJS) Agora vamos construir o mesmo fluxo usando um esquema. Instalação npm instalar pesquisa-core pesquisa-react-ui @tanstack/react-query
Survey-coreO mecanismo de tempo de execução independente de plataforma licenciado pelo MIT que alimenta a renderização de formulários do SurveyJS - a parte que nos interessa aqui. Ele pega um esquema JSON, constrói um modelo interno a partir dele e lida com tudo que de outra forma residiria em seu componente React: avaliando expressões de visibilidade, calculando valores derivados, gerenciando o estado da página, rastreando a validação e decidindo o que significa “completo” considerando quais páginas foram realmente mostradas.
survey-react-uiA camada de UI/renderização que conecta esse modelo ao React. É essencialmente um componente
Juntos, eles fornecem um tempo de execução de formulário de várias páginas totalmente funcional, sem escrever uma única linha de fluxo de controle. O formato do esquema em si é, como dito antes, apenas um JSON – sem DSL ou qualquer coisa proprietária. Você pode incorporá-lo, importá-lo de um arquivo, buscá-lo em uma API ou armazená-lo em uma coluna de banco de dados e hidratá-lo em tempo de execução. O mesmo formulário, como dados Aqui está o mesmo formato, desta vez expresso como um objeto JSON. O esquema define tudo: estrutura, validação, regras de visibilidade, cálculos derivados, navegação de página — e entrega tudo a um modelo que o avalia em tempo de execução. Aqui está o que parece na íntegra:
export const surveySchema = { title: "Order Flow", showProgressBar: "top", páginas: [ { name: "details", elementos: [ { type: "text", name: "firstName", isRequired: true }, { type: "text", name: "email", inputType: "email", isRequired: true, validators: [{ type: "email", text: "Invalid email" }] } ] }, { name: "order", elementos: [ { tipo: "texto", nome: "preço", inputType: "número", defaultValue: 0 }, { tipo: "texto", nome: "quantidade", inputType: "número", defaultValue: 1 }, { tipo: "dropdown",nome: "taxRate", defaultValue: 0,1, escolhas: [ { valor: 0,05, texto: "5%" }, { valor: 0,1, texto: "10%" }, { valor: 0,15, texto: "15%" } ] }, { tipo: "expressão", nome: "subtotal", expressão: "{preço} {quantidade}" }, { tipo: "expressão", nome: "imposto", expressão: "{subtotal} {taxRate}" }, { tipo: "expressão", nome: "total", expressão: "{subtotal} + {imposto}" } ] }, { nome: "conta", elementos: [ { tipo: "radiogroup", nome: "hasAccount", escolhas: ["Sim", "Não"] }, { tipo: "texto", nome: "nome de usuário", visívelIf: "{hasAccount} = 'Sim'", isRequired: true }, { type: "text", name: "password", inputType: "password", visívelIf: "{hasAccount} = 'Yes'", isRequired: true, validadores: [{ type: "text", minLength: 6, text: "Min 6 caracteres" }] }, { type: "rating", name: "satisfaction", rateMin: 1, rateMax: 5 }, { type: "comentário", nome: "feedback positivo", visívelIf: "{satisfação} >= 4" }, { tipo: "comentário", nome: "improvementFeedback", visívelIf: "{satisfação} <= 2" } ] }, { nome: "revisão", visívelIf: "{total} >= 100", elementos: [] } ]};
Compare isso com a versão RHF por um momento.
O bloco superRefine que exigia nome de usuário e senha condicionalmente desapareceu. visívelIf: "{hasAccount} = 'Yes'" combinado com isRequired: true trata ambas as preocupações juntas, no próprio campo, onde você espera encontrá-las. A cadeia useWatch + useMemo que calculou subtotal, imposto e total é substituída por três campos de expressão que fazem referência entre si por nome. A condição da página de revisão, que na versão RHF era reconstrutível apenas rastreando através de showSubmit, a ramificação de renderização da etapa 3. E, finalmente, a lógica do botão de navegação é uma única propriedade visívelIf no objeto de página.
A mesma lógica está aí. Acontece apenas que o esquema oferece um local onde ele fica visível isoladamente, em vez de se espalhar pelo componente. Além disso, observe que o esquema usa tipo: 'expressão' para subtotal, imposto e total. A expressão é somente leitura e usada principalmente para exibir valores calculados. SurveyJS também suporta type: 'html' para conteúdo estático, mas para valores calculados, expressão é a escolha certa. Agora, para o lado React. Renderização e envio Muito simples. Conecte onComplete à sua API da mesma maneira - via useMutation ou busca simples:
importar { useState, useEffect, useRef } de "react"; importar { useMutation } de "@tanstack/react-query"; importar { Model } de "survey-core"; importar { Survey } de "survey-react-ui";
função de exportação SurveyForm() { const [model] = useState(() => new Model(surveySchema));
mutação const = useMutation({ mutaçãoFn: assíncrono (dados) => { const res = aguarda fetch("/api/orders", { método: "POSTAR", cabeçalhos: { "Content-Type": "application/json" }, corpo: JSON.stringify(dados), }); if (!res.ok) throw new Error("Falha ao enviar"); retornar res.json(); }, });
const mutaçãoRef = useRef(mutação); mutaçãoRef.atual = mutação; useEffect(() => { const handler = (sender) =>mutationRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [model]); // ref evita registrar novamente o manipulador a cada renderização (alterações na identidade do objeto de mutação)
retornar (
<>
Veja o Pen SurveyJS-03-SurveyJS [bifurcado] pela sexta extinção.
onComplete é acionado quando o usuário chega ao final da última página visível. Portanto, se o total nunca ultrapassar 100 e a página de revisão for ignorada, ela ainda será acionada corretamente porque o SurveyJS avalia a visibilidade antes de decidir o que significa “última página”. Então, sender.data contém todas as respostas junto com os valores calculados (subtotal, imposto, total) como campos de primeira classe, de modo que a carga útil da API é idêntica à versão RHF montada manualmente no onSubmit. OO padrãomutationRef é o mesmo que você usaria em qualquer lugar onde precisasse de um manipulador de eventos estável sobre um valor que muda a cada renderização - nada específico do SurveyJS sobre isso.
O componente React não contém mais nenhuma lógica de negócios. Não há useWatch, nem JSX condicional, nem contador de passos, nem cadeia useMemo, nem superRefine. O React está fazendo o que realmente é bom: renderizar um componente e conectá-lo a uma chamada de API. O que saiu do React?
Preocupação Pilha RHF Pesquisa JS Visibilidade Ramos JSX visívelSe Valores derivados useWatch / useMemo expressão Regras entre campos superRefinar Condições do esquema Navegação estado de passo Página visívelSe Localização da regra Distribuído entre arquivos Centralizado no esquema
O que permanece no React é o layout, o estilo, a fiação de envio e a integração do aplicativo, ou seja, as coisas para as quais o React foi realmente projetado. Todo o resto foi movido para o esquema e, como o esquema é apenas um objeto JSON, ele pode ser armazenado em um banco de dados, versionado independentemente do código do seu aplicativo ou editado por meio de ferramentas internas sem a necessidade de implantação. Um gerente de produto que precisa alterar o limite que aciona a página de revisão pode fazer isso sem tocar no componente. Essa é uma diferença operacional significativa para equipes onde o comportamento do formulário evolui com frequência e nem sempre é orientado por engenheiros. Quando usar cada abordagem? Aqui está uma boa regra que funciona para mim: imagine excluir totalmente o formulário. O que você perderia?
Se forem telas, você deseja formulários baseados em componentes. Se for lógica de negócios, como limites, regras de ramificação e requisitos condicionais que codificam decisões reais, você deseja um mecanismo de esquema.
Da mesma forma, se as mudanças que ocorrerem forem principalmente sobre rótulos, campos e layout, o RHF será útil para você. Se forem sobre condições, resultados e regras que sua equipe de operações ou jurídica pode precisar ajustar em uma tarde de terça-feira sem registrar um ticket, o modelo de esquema com SurveyJS é a opção mais honesta. Estas duas abordagens não estão realmente em concorrência entre si. Eles abordam diferentes classes de problemas, e o erro que vale a pena evitar é a incompatibilidade entre a abstração e o peso da lógica – tratando um sistema de regras como um componente porque essa é a ferramenta familiar, ou recorrendo a um mecanismo de política porque um formulário cresceu para três etapas e adquiriu um campo condicional. A forma que construímos aqui fica deliberadamente perto do limite, complexa o suficiente para expor a diferença, mas não tão extrema que a comparação pareça fraudulenta. A maioria dos formulários reais que se tornaram difíceis de manejar em sua base de código provavelmente ficam próximos ao mesmo limite, e a questão geralmente é se alguém nomeou o que eles realmente são. Use React Hook Form + Zod quando:
Os formulários são orientados para CRUD; A lógica é superficial e orientada pela UI; Os engenheiros são donos de todo comportamento; O back-end continua sendo a fonte da verdade.
Use SurveyJS quando:
Os formulários codificam decisões de negócios; As regras evoluem independentemente da IU; A lógica deve ser visível, auditável ou versionada; Os não-engenheiros influenciam o comportamento; O mesmo formulário deve ser executado em vários frontends.