Padrões de Formulário - Básico
Padrões de copiar e colar para os formulários mais comuns - login, cadastro e contato - com validação Zod e UX adequada.
Busque em todas as páginas da documentação
Padrões de copiar e colar para os formulários mais comuns - login, cadastro e contato - com validação Zod e UX adequada.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida - pronto para copiar e colar.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Esquema de login
const LoginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(1, "Senha é obrigatória"),
});
type LoginData = z.infer<typeof LoginSchema>;
function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => Promise<void> }) {
const { register, handleSubmit, formState: { errors, isSubmitting }, setError } = useForm<LoginData>({
resolver: zodResolver(LoginSchema),
});
async function handleLogin(data: LoginData) {
try {
await onSubmit(data);
} catch {
setError("root", { message: "Email ou senha inválidos" });
}
}
return (
<form onSubmit={handleSubmit(handleLogin)}>
{errors.root && <p className="text-red-600">{errors.root.message}</p>}
<input {...register("email")} type="email" placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" placeholder="Senha" />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Entrando..." : "Entrar"}</button>
</form>
);
}Quando usar isso: Ao construir fluxos de autenticação ou contato padrão - esses padrões cobrem 80% dos formulários em um aplicativo típico.
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// --- Formulário de Cadastro ---
const SignupSchema = z
.object({
name: z.string().min(2, "O nome deve ter pelo menos 2 caracteres"),
email: z.string().email("Por favor, insira um email válido"),
password: z
.string()
.min(8, "Pelo menos 8 caracteres")
.regex(/[A-Z]/, "Precisa de uma letra maiúscula")
.regex(/[0-9]/, "Precisa de um número"),
confirmPassword: z.string(),
terms: z.literal(true, { errorMap: () => ({ message: "Você deve aceitar os termos" }) }),
})
.refine((d) => d.password === d.confirmPassword, {
message: "As senhas não coincidem",
path: ["confirmPassword"],
});
type SignupData = z.infer<typeof SignupSchema>;
export function SignupForm() {
const [success, setSuccess] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<SignupData>({
resolver: zodResolver(SignupSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "", terms: false as any },
});
async function onSubmit(data: SignupData) {
try {
const res = await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const body = await res.json();
if (body.field) {
setError(body.field, { message: body.message });
} else {
setError("root", { message: body.message || "Algo deu errado" });
}
return;
}
setSuccess(true);
} catch {
setError("root", { message: "Erro de rede. Por favor, tente novamente." });
}
}
if (success) {
return <p className="text-green-600 font-medium">Conta criada! Verifique seu email.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
{errors.root && (
<div className="rounded bg-red-50 p-3 text-sm text-red-700">{errors.root.message}</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium">Nome</label>
<input id="name" {...register("name")} className="w-full rounded border p-2" />
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" {...register("email")} type="email" className="w-full rounded border p-2" />
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">Senha</label>
<input id="password" {...register("password")} type="password" className="w-full rounded border p-2" />
{errors.password && <p className="text-sm text-red-600">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirm" className="block text-sm font-medium">Confirmar Senha</label>
<input id="confirm" {...register("confirmPassword")} type="password" className="w-full rounded border p-2" />
{errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
</div>
<div className="flex items-center gap-2">
<input id="terms" type="checkbox" {...register("terms")} />
<label htmlFor="terms" className="text-sm">Eu aceito os termos e condições</label>
</div>
{errors.terms && <p className="text-sm text-red-600">{errors.terms.message}</p>}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Criando conta..." : "Criar Conta"}
</button>
</form>
);
}O que isso demonstra:
z.literal(true)setError para erros de campo e de nível raizhtmlForzodResolver conecta o esquema Zod ao pipeline de validação do react-hook-formsetError("root", ...) define um erro em nível de formulário (por exemplo, "Credenciais inválidas") não vinculado a um camposetError("email", ...) define um erro em nível de campo de respostas do servidor (por exemplo, "Email já em uso")isSubmitting é true enquanto a promessa onSubmit está pendente - use-o para desabilitar o botão e mostrar o texto de carregamentoz.literal(true) para caixas de seleção garante que a caixa deva ser marcadaFormulário de contato com dropdown de assunto:
const ContactSchema = z.object({
name: z.string().min(1, "Nome obrigatório"),
email: z.string().email(),
subject: z.enum(["general", "support", "billing", "partnership"]),
message: z.string().min(10).max(2000),
priority: z.enum(["low", "normal", "high"]).default("normal"),
});Login com botões OAuth:
function LoginPage() {
return (
<div className="space-y-4">
<LoginForm onSubmit={handleEmailLogin} />
<div className="relative text-center text-sm text-gray-500">
<span className="bg-white px-2">ou continue com</span>
</div>
<div className="flex gap-2">
<button onClick={() => signIn("google")} className="flex-1 rounded border p-2">Google</button>
<button onClick={() => signIn("github")} className="flex-1 rounded border p-2">GitHub</button>
</div>
</div>
);
}Fluxo de esqueci minha senha:
const ForgotSchema = z.object({ email: z.string().email() });
const ResetSchema = z
.object({
token: z.string(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "As senhas não coincidem",
path: ["confirmPassword"],
});// Tipagem de resposta de erro do servidor
type ApiError = { field?: keyof SignupData; message: string };
// setError type-safe
const { setError } = useForm<SignupData>();
setError("email", { message: "Já em uso" }); // OK
setError("typo", { message: "..." }); // Erro TS
// Componente de campo de formulário reutilizável
function Field<T extends FieldValues>({
name, label, form, type = "text",
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
type?: string;
}) {
return (
<div>
<label className="block text-sm font-medium">{label}</label>
<input type={type} {...form.register(name)} className="w-full rounded border p-2" />
{form.formState.errors[name] && (
<p className="text-sm text-red-600">{form.formState.errors[name]?.message as string}</p>
)}
</div>
);
}"Credenciais inválidas" genérico para login - Nunca revele se o email ou a senha estavam errados. Correção: Sempre exiba "Email ou senha inválidos" como um erro raiz.
Campo de senha não preservado em caso de erro - Navegadores limpam campos de senha na reenvio do formulário. Este é um comportamento de segurança esperado; não tente preencher senhas novamente.
Caixa de seleção com register - register para caixas de seleção funciona, mas retorna uma string "on" ou undefined. Correção: Use z.literal(true) e certifique-se de que o valor da caixa de seleção seja mapeado para um booleano, ou use Controller.
Limitação de taxa - Formulários de login e cadastro são alvos principais para ataques de força bruta. Correção: Adicione limitação de taxa no servidor e exiba mensagens de erro apropriadas.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| Server Actions forms | Você quer aprimoramento progressivo sem JavaScript no cliente | Você precisa de validação instantânea de campo |
| Auth.js (NextAuth) | Você precisa de autenticação completa com OAuth, sessões, etc. | Você só precisa de um formulário de login simples |
| Clerk / Auth0 | Você quer autenticação gerenciada com UI pré-construída | Você precisa de controle total sobre o fluxo de autenticação |
| shadcn Form | Você quer UI polida com esforço mínimo | Você está construindo um formulário headless |
z.literal(true) exige que o valor seja exatamente true, não apenas truthyerrorMap: z.literal(true, { errorMap: () => ({ message: "Você deve aceitar" }) })errors.root?.messageconst body = await res.json();
if (body.field) {
setError(body.field, { message: body.message });
}setError("email", { message: "Já em uso" }) anexa o erro ao campo de email.refine() opera em todo o objeto, então ele pode comparar password e confirmPasswordpath: ["confirmPassword"] para anexar o erro ao campo corretoisSubmitting é true enquanto a promessa onSubmit está pendentefalse quando a promessa é resolvida ou rejeitadasetError("root", { message: "Email ou senha inválidos" })register em uma caixa de seleção retorna "on" ou undefined em vez de um booleanoz.literal(true) do Zod espera um booleano, causando uma incompatibilidade de tipoController para caixas de seleção, ou certifique-se de que o valor seja mapeado para um booleanofunction Field<T extends FieldValues>({
name, label, form,
}: {
name: FieldPath<T>;
label: string;
form: UseFormReturn<T>;
}) {
return <input {...form.register(name)} />;
}FieldPath<T> restringe name a caminhos de campo válidos do tipo do formuláriotype ApiError = { field?: keyof SignupData; message: string };field a keyof SignupData garante que apenas nomes de campo válidos possam ser passados para setErroruseState(false) rastreia se o cadastro foi bem-sucedidotrue e renderize uma mensagem de sucesso em vez do formuláriozodResolver(Schema) adapta um esquema Zod à interface de resolver do react-hook-formschema.safeParse() e mapeia erros Zod para o formato FieldErrors do RHF@hookform/resolvers/zodRevisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥