Existe um modelo mental que a maioria dos desenvolvedores do React compartilha sem nunca discuti-lo em voz alta. Que os formulários são sempre deveriam ser componentes. Isso significa uma pilha como:
- Formulário de gancho de reação para o 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).
- Consulta de reação para back-end: envio, novas tentativas, armazenamento em 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 um branch inline, o que é bom. Depois outro. Então você está alcançando superRefine para codificar regras entre campos 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 além do ponto em que suas abstrações correspondem ao problema porque a alternativa requer uma maneira 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:
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.
import { z } from "zod";
export const formSchema = z.object({
firstName: z.string().min(1, "Required"),
email: z.string().email("Invalid email"),
price: z.number().min(0),
quantity: z.number().min(1),
taxRate: z.number(),
hasAccount: z.enum(["Yes", "No"]),
username: z.string().optional(),
password: z.string().optional(),
satisfaction: z.number().min(1).max(5),
positiveFeedback: z.string().optional(),
improvementFeedback: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.hasAccount === "Yes") {
if (!data.username) {
ctx.addIssue({ code: "custom", path: ["username"], message: "Required" });
}
if (!data.password || data.password.length < 6) {
ctx.addIssue({ code: "custom", path: ["password"], message: "Min 6 characters" });
}
}
if (data.satisfaction >= 4 && !data.positiveFeedback) {
ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "Please share what you liked" });
}
if (data.satisfaction <= 2 && !data.improvementFeedback) {
ctx.addIssue({ code: "custom", path: ["improvementFeedback"], message: "Please tell us what to improve" });
}
});
export type FormData = z.infer;
Observe que username e password são digitados como optional() mesmo que sejam condicionalmente necessários porque o esquema de nível de tipo do Zod descreve o forma do objeto, não as regras que regem quando os campos são importantes.
O requisito condicional tem que viver dentro superRefineque é 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 expressar. 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
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import { formSchema, type FormData } from "./schema";
const STEPS = ["details", "order", "account", "review"];
type OrderPayload = FormData & { subtotal: number; tax: number; total: number };
export function RHFMultiStepForm() {
const [step, setStep] = useState(0);
const mutation = useMutation({
mutationFn: async (payload: OrderPayload) => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Failed to submit");
return res.json();
},
});
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
price: 0,
quantity: 1,
taxRate: 0.1,
satisfaction: 3,
hasAccount: "No",
},
});
const price = useWatch({ control, name: "price" });
const quantity = useWatch({ control, name: "quantity" });
const taxRate = useWatch({ control, name: "taxRate" });
const hasAccount = useWatch({ control, name: "hasAccount" });
const satisfaction = useWatch({ control, name: "satisfaction" });
const subtotal = useMemo(() => (price ?? 0) * (quantity ?? 1), [price, quantity]);
const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]);
const total = useMemo(() => subtotal + tax, [subtotal, tax]);
const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total });
const showSubmit = (step === 2 && total < 100) || (step === 3 && total >= 100)
return (
);
}
Veja a caneta [SurveyJS-03-RHF [forked]](https://codepen.io/smashingmag/pen/gbwwmNO) por sexta extinção.
Há muita coisa acontecendo aqui e vale a pena desacelerar para perceber onde as coisas foram parar.
- Os valores derivados –
subtotal,tax,total— são calculados no componente viauseWatcheuseMemoporque eles dependem de valores de campos ativos e não há outro lugar natural para eles. - As regras de visibilidade para
username,password,positiveFeedbackeimprovementFeedbackviva em JSX como condicionais embutidas. - A lógica de pular etapas – a página de revisão aparece apenas quando
total >= 100– está incorporado noshowSubmitvariável e a condição de renderização na etapa 3. - A navegação em si é apenas um
useStatecontador que estamos incrementando manualmente. - React Query lida com novas tentativas, armazenamento em cache e invalidação. O formulário apenas chama
mutation.mutatecom dados validados.
Nada disso é 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 apareceeles teriam que rastrear showSubmita 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 install survey-core survey-react-ui @tanstack/react-query
survey-core
O 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-ui
A UI/camada de renderização que conecta esse modelo ao React. É essencialmente umcomponente que é renderizado novamente sempre que o estado do mecanismo muda. As bibliotecas de UI SurveyJS também estão disponíveis para Angular, Vue3 e muitas outras estruturas.
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 na página – e entrega tudo a um Model que o avalia em tempo de execução. Aqui está o que parece na íntegra:
export const surveySchema = {
title: "Order Flow",
showProgressBar: "top",
pages: [
{
name: "details",
elements: [
{ type: "text", name: "firstName", isRequired: true },
{ type: "text", name: "email", inputType: "email", isRequired: true, validators: [{ type: "email", text: "Invalid email" }] }
]
},
{
name: "order",
elements: [
{ type: "text", name: "price", inputType: "number", defaultValue: 0 },
{ type: "text", name: "quantity", inputType: "number", defaultValue: 1 },
{
type: "dropdown",
name: "taxRate",
defaultValue: 0.1,
choices: [
{ value: 0.05, text: "5%" },
{ value: 0.1, text: "10%" },
{ value: 0.15, text: "15%" }
]
},
{
type: "expression",
name: "subtotal",
expression: "{price} * {quantity}"
},
{
type: "expression",
name: "tax",
expression: "{subtotal} * {taxRate}"
},
{
type: "expression",
name: "total",
expression: "{subtotal} + {tax}"
}
]
},
{
name: "account",
elements: [
{
type: "radiogroup",
name: "hasAccount",
choices: ["Yes", "No"]
},
{
type: "text",
name: "username",
visibleIf: "{hasAccount} = 'Yes'",
isRequired: true
},
{
type: "text",
name: "password",
inputType: "password",
visibleIf: "{hasAccount} = 'Yes'",
isRequired: true,
validators: [{ type: "text", minLength: 6, text: "Min 6 characters" }]
},
{
type: "rating",
name: "satisfaction",
rateMin: 1,
rateMax: 5
},
{
type: "comment",
name: "positiveFeedback",
visibleIf: "{satisfaction} >= 4"
},
{
type: "comment",
name: "improvementFeedback",
visibleIf: "{satisfaction} <= 2"
}
]
},
{
name: "review",
visibleIf: "{total} >= 100",
elements: []
}
]
};
Compare isso com a versão RHF por um momento.
- O
superRefinebloquear aquele condicionalmente necessáriousernameepassworddesapareceu.visibleIf: "{hasAccount} = 'Yes'"combinado comisRequired: truelida com ambas as preocupações juntas, no próprio campo, onde você esperaria encontrá-las. - O
useWatch+useMemocadeia que calculousubtotal,taxetotalé substituído por trêsexpressioncampos que fazem referência entre si por nome. - A condição da página de revisão, que na versão RHF só era reconstruída rastreando
showSubmito branch de renderização da etapa 3. - E finalmente, a lógica do botão de navegação é uma única
visibleIfpropriedade no objeto de página.
A mesma lógica está aí. Acontece que o esquema oferece a ele um local para morar, onde é visível isoladamente, em vez de se espalhar pelo componente.
Além disso, observe que o esquema usa type: 'expression' 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, expression é a escolha certa.
Agora, para o lado React.
Renderização e envio
Muito simples. Arame onComplete à sua API da mesma maneira – via useMutation ou simples fetch:
import { useState, useEffect, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { Model } from "survey-core";
import { Survey } from "survey-react-ui";
import "survey-core/survey-core.css";
export function SurveyForm() {
const [model] = useState(() => new Model(surveySchema));
const mutation = useMutation({
mutationFn: async (data) => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to submit");
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 avoids re-registering handler every render (mutation object identity changes)
return (
<>
{mutation.isError && Error: {mutation.error.message}
}
>
);
}
Veja a caneta [SurveyJS-03-SurveyJS [forked]](https://codepen.io/smashingmag/pen/emddWNV) por sexta extinção.
onCompletedispara quando o usuário chega ao final do último visível página. Então setotalnunca ultrapassa 100 e a página de revisão é ignorada, ela ainda é acionada corretamente porque o SurveyJS avalia a visibilidade antes de decidir o que significa “última página”.- Então,
sender.datacontém todas as respostas junto com os valores calculados (subtotal,tax,total) como campos de primeira classe, portanto a carga útil da API é idêntica à versão RHF montada manualmente emonSubmit. - O
mutationRefpadrão é 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á useWatchsem JSX condicional, sem contador de passos, sem useMemo cadeia, não 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 | visibleIf |
| Valores derivados | useWatch / useMemo | expression |
| Regras entre campos | superRefine | Condições do esquema |
| Navegação | step estado | Página visibleIf |
| 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 o formulário completamente. 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.

