Formulários Escaláveis com React Hook Form, Zod e Server Actions

Arquitetura Moderna para CRUDs no Next.js
- Parte 1: Arquitetando um CRUD Escalável
- Parte 2: Construindo uma Camada Profissional de Comunicação
- Parte 3: Gerenciando Estado Remoto com TanStack Query
- 👉 Parte 4: Formulários Escaláveis com React Hook Form + Zod (Você está aqui)
- Parte 5: Search Params, Filtros e Paginação (Em breve)
- Parte 6: UX Moderna: Optimistic Updates e Performance (Em breve)
Até aqui resolvemos o problema do estado remoto.
Agora vamos resolver outro tipo de estado completamente diferente: o estado do formulário.
Esses dois problemas costumam ser confundidos, mas possuem responsabilidades distintas.
- TanStack Query administra dados vindos do servidor.
- React Hook Form administra os dados enquanto o usuário está preenchendo um formulário.
Separar essas responsabilidades é um dos pilares de uma arquitetura escalável.
O Caos dos Formulários Tradicionais
Até agora, nossa aplicação sabe buscar e armazenar dados com maestria. Mas um CRUD não vive apenas de leitura. Precisamos criar e editar esses dados.
E é aqui que o React costuma frustrar muitos desenvolvedores.
Se você já criou um formulário no React usando a abordagem tradicional (Componentes Controlados), provavelmente escreveu algo assim:
import { useState } from "react";
export function UserForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [errors, setErrors] = useState({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Início do pesadelo das validações manuais
if (!name) {
setErrors((prev) => ({ ...prev, name: "Nome obrigatório" }));
}
if (!email.includes("@")) {
setErrors((prev) => ({ ...prev, email: "E-mail inválido" }));
}
};
}
Para um formulário de login com apenas dois campos, isso funciona.
Mas imagine um cadastro de produtos com:
- 20 campos;
- múltiplas abas;
- upload de imagens;
- campos condicionais;
- validações complexas.
Para cada letra digitada no campo Nome, o setName é chamado e todo o componente pode ser re-renderizado.
Além da perda de performance, existe um problema ainda maior: a arquitetura.
Você espalha dezenas de ifs pelo código para validar regras de negócio, mistura estado da interface com estado do formulário e perde uma fonte única de verdade para validar seus dados.
Precisamos de uma arquitetura melhor.
A Fonte Única da Verdade (Zod)
Antes mesmo de pensar em inputs, precisamos responder uma pergunta simples:
Como sabemos se um usuário é válido?
Enquanto essa resposta não existir, qualquer formulário será apenas uma coleção de campos.
É por isso que começamos pelo Schema, e não pelo formulário.
Primeiro definimos o contrato.
Depois construímos a interface que respeita esse contrato.
Essa ordem faz muito mais sentido arquiteturalmente.
Na nossa pasta de schemas:
// src/schemas/user.schema.ts
import { z } from "zod";
export const createUserSchema = z.object({
name: z
.string()
.min(3, "O nome deve ter no mínimo 3 caracteres."),
email: z
.string()
.email("Digite um e-mail válido."),
role: z.enum(["ADMIN", "USER"], {
required_error: "Selecione o nível de acesso.",
}),
});
// O TypeScript infere o tipo automaticamente.
export type CreateUserData = z.infer<typeof createUserSchema>;
Observe que não escrevemos nenhuma interface manualmente.
O próprio TypeScript infere os tipos a partir do Schema.
Isso significa que validação e tipagem evoluem juntas.
Se amanhã adicionarmos um novo campo obrigatório ao Schema, o TypeScript imediatamente apontará todos os lugares que precisam ser atualizados.
Temos apenas uma fonte de verdade.
Administrando o Estado do Formulário (React Hook Form)
Agora que temos as regras, precisamos responder outra pergunta:
Como coletamos os dados do formulário e administramos estados como
errors,isSubmittingouisValid?
É exatamente esse problema que o React Hook Form resolve.
Diferentemente da abordagem tradicional, ele utiliza inputs não controlados sempre que possível, reduzindo drasticamente a quantidade de estados do React e evitando re-renderizações desnecessárias.
Mas o verdadeiro ganho não é apenas performance.
O RHF entrega:
- uma API extremamente simples;
- gerenciamento completo do estado do formulário;
- integração nativa com validação;
- componentes desacoplados.
Quando combinamos RHF com Zod através do zodResolver, toda a validação passa a acontecer automaticamente.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
createUserSchema,
CreateUserData,
} from "@/schemas/user.schema";
export function CreateUserForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateUserData>({
resolver: zodResolver(createUserSchema),
});
function onSubmit(data: CreateUserData) {
console.log(data);
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div>
<input
{...register("name")}
placeholder="Nome completo"
/>
{errors.name && (
<span className="text-red-500">
{errors.name.message}
</span>
)}
</div>
<div>
<input
{...register("email")}
placeholder="E-mail"
/>
{errors.email && (
<span className="text-red-500">
{errors.email.message}
</span>
)}
</div>
<button type="submit">
Salvar Usuário
</button>
</form>
);
}
Nenhum useState.
Nenhum if de validação manual.
Se o usuário tentar enviar o formulário vazio, o handleSubmit impede a execução do onSubmit e os erros definidos no Zod são enviados automaticamente para a interface.
Cada Ferramenta Resolve um Problema
Neste ponto da série, nossa arquitetura começa a ficar bastante clara.
Cada biblioteca possui uma responsabilidade específica.
| Ferramenta | Responsabilidade | |------------|------------------| | React Hook Form | Estado do formulário | | Zod | Contrato e validação | | TanStack Query | Estado remoto e cache | | Server Actions | Executar código seguro no servidor | | Services | Comunicação agnóstica com a API | | Axios | Transporte HTTP |
Esse é um detalhe importante.
Nenhuma dessas bibliotecas substitui outra.
Na verdade, elas trabalham em conjunto.
Enquanto o React Hook Form administra o preenchimento dos campos, o TanStack Query continua responsável apenas pela comunicação assíncrona e sincronização do estado remoto.
Cada camada permanece especializada em um único problema.
Fechando o Ciclo: A Mutação e o Servidor
Temos a validação (Zod) e a interface otimizada (React Hook Form).
Agora precisamos enviar esses dados ao servidor.
1. Protegendo a Server Action com Zod
Toda validação feita no navegador pode ser ignorada.
Um usuário pode desabilitar o JavaScript ou enviar uma requisição diretamente pelo Postman.
Por isso, o servidor deve considerar qualquer entrada como não confiável.
A solução é reutilizar exatamente o mesmo Schema.
// src/actions/users/create-user-action.ts
"use server";
import { createUserSchema } from "@/schemas/user.schema";
import { createUserService } from "@/services/users.service";
export async function createUserAction(data: unknown) {
const validData = createUserSchema.parse(data);
return createUserService(validData);
}
Observe que escrevemos as regras de validação apenas uma vez.
Elas são reutilizadas tanto no cliente quanto no servidor.
2. Sincronizando o Cache com TanStack Query
Agora atualizamos o envio do formulário para utilizar uma mutação.
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createUserAction } from "@/actions/users/create-user-action";
// ... imports do RHF e Zod
export function CreateUserForm() {
const queryClient = useQueryClient();
// ... setup do useForm
const { mutate, isPending } = useMutation({
mutationFn: createUserAction,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["users"],
});
alert("Usuário salvo com sucesso!");
},
});
function onSubmit(data: CreateUserData) {
mutate(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Inputs */}
<button
type="submit"
disabled={isPending}
>
{isPending ? "Salvando..." : "Salvar Usuário"}
</button>
</form>
);
}
Essa linha conecta diretamente este artigo à Parte 3 da série.
queryClient.invalidateQueries({
queryKey: ["users"],
});
Naquele momento aprendemos que ["users"] representa a identidade da lista de usuários dentro do cache.
Ao invalidarmos essa chave, informamos ao TanStack Query que aqueles dados já não são mais confiáveis.
Na próxima consulta, uma nova requisição será realizada automaticamente e toda a interface será sincronizada.
O Fluxo Arquitetural Completo
Ao criar um usuário, toda a aplicação segue este fluxo:
Componente
↓
React Hook Form
↓
Zod
↓
TanStack Query (Mutation)
↓
Server Action
↓
Service
↓
Axios
↓
API
Esse fluxo resume toda a evolução da série até aqui.
Cada camada possui uma responsabilidade clara e isolada.
Conclusão
Observe como a arquitetura continua evoluindo sem que nenhuma camada anterior precise ser modificada.
Os Services continuam responsáveis pela comunicação com a API.
O TanStack Query continua administrando o estado remoto.
Agora adicionamos duas novas responsabilidades:
- React Hook Form, para administrar o estado do formulário.
- Zod, para definir e validar o contrato dos dados.
Cada biblioteca faz apenas uma coisa, mas faz isso muito bem.
Essa separação de responsabilidades é justamente o que torna a arquitetura escalável.
No próximo artigo resolveremos outro desafio muito comum em aplicações reais:
Como manter filtros, busca e paginação sincronizados com a URL, permitindo compartilhar links e preservar o estado da tela durante a navegação?
É exatamente isso que construiremos utilizando Search Params e TanStack Query.