RHF + Zod
Integre esquemas Zod com react-hook-form via @hookform/resolvers/zod para formulários totalmente tipados e orientados por esquema.
Busque em todas as páginas da documentação
Integre esquemas Zod com react-hook-form via @hookform/resolvers/zod para formulários totalmente tipados e orientados por esquema.
🤖 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";
const Schema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(8, "Mínimo de 8 caracteres"),
});
type FormData = z.infer<typeof Schema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Entrar</button>
</form>
);
}Quando usar isso: Quando você deseja regras de validação definidas por esquema (Zod) alimentando a exibição de erros em nível de campo de alto desempenho do react-hook-form.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const ProfileSchema = z
.object({
username: z
.string()
.min(3, "Mínimo de 3 caracteres")
.max(20)
.regex(/^[a-z0-9_]+$/, "Apenas letras minúsculas, números e underscores"),
displayName: z.string().min(1, "Obrigatório"),
bio: z.string().max(500).optional(),
website: z.string().url("URL inválida").optional().or(z.literal("")),
newPassword: z.string().min(8).optional().or(z.literal("")),
confirmPassword: z.string().optional().or(z.literal("")),
})
.refine(
(data) => {
if (data.newPassword && data.newPassword !== data.confirmPassword) return false;
return true;
},
{ message: "As senhas devem coincidir", path: ["confirmPassword"] }
);
type ProfileFormData = z.infer<typeof ProfileSchema>;
export function ProfileEditor() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
reset,
} = useForm<ProfileFormData>({
resolver: zodResolver(ProfileSchema),
defaultValues: {
username: "johndoe",
displayName: "John Doe",
bio: "",
website: "",
newPassword: "",
confirmPassword: "",
},
mode: "onBlur",
});
async function onSubmit(data: ProfileFormData) {
await new Promise((r) => setTimeout(r, 1000)); // simula API
console.log("Salvo:", data);
reset(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
<div>
<label className="block text-sm font-medium">Nome de usuário</label>
<input {...register("username")} className="w-full rounded border p-2" />
{errors.username && <p className="text-sm text-red-600">{errors.username.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Nome de Exibição</label>
<input {...register("displayName")} className="w-full rounded border p-2" />
{errors.displayName && <p className="text-sm text-red-600">{errors.displayName.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Bio</label>
<textarea {...register("bio")} rows={3} className="w-full rounded border p-2" />
{errors.bio && <p className="text-sm text-red-600">{errors.bio.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Website</label>
<input {...register("website")} placeholder="https://..." className="w-full rounded border p-2" />
{errors.website && <p className="text-sm text-red-600">{errors.website.message}</p>}
</div>
<fieldset className="rounded border p-3">
<legend className="px-1 text-sm font-medium">Alterar Senha (opcional)</legend>
<div className="space-y-2">
<input {...register("newPassword")} type="password" placeholder="Nova senha" className="w-full rounded border p-2" />
{errors.newPassword && <p className="text-sm text-red-600">{errors.newPassword.message}</p>}
<input {...register("confirmPassword")} type="password" placeholder="Confirmar" className="w-full rounded border p-2" />
{errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
</div>
</fieldset>
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Salvando..." : "Salvar Perfil"}
</button>
</form>
);
}O que isso demonstra:
zodResolver com useForm.refine() em nível de objeto para validação entre campos com direcionamento de caminho (path).or(z.literal("")))mode: "onBlur" para comportamento de validação ao perder o focoisDirty) e de submissão (isSubmitting)zodResolver(schema) retorna uma função que corresponde ao tipo Resolver do RHF.schema.safeParse(values) e mapeia ZodError.issues para o formato FieldErrors do RHF.errors.fieldName..refine() em nível de esquema são mapeados para o path que você especificar, ou para root se nenhum caminho for fornecido.Acessando erros de nível raiz:
const { formState: { errors } } = useForm({ resolver: zodResolver(schema) });
// Erro raiz de um .refine() sem path
errors.root?.message;Resolver com mapeamento de erro personalizado:
useForm({
resolver: zodResolver(Schema, {
// Passa opções do Zod
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});Esquema com validação assíncrona:
const Schema = z.object({
username: z.string().refine(async (val) => {
const available = await checkUsername(val);
return available;
}, "Nome de usuário já em uso"),
});
// zodResolver lida com assíncrono automaticamente
useForm({ resolver: zodResolver(Schema) });// O tipo genérico flui do esquema
const Schema = z.object({ name: z.string() });
type T = z.infer<typeof Schema>;
// useForm é tipado pelo genérico, não pelo resolver
const form = useForm<T>({ resolver: zodResolver(Schema) });
// Se os tipos não coincidirem entre z.infer e o genérico, o TS pega
const BadSchema = z.object({ email: z.string() });
// useForm<T>({ resolver: zodResolver(BadSchema) }) - sem erro TS no nível do resolver
// mas register("name") ainda será type-safe contra T
// Melhor prática: derive o tipo do esquema
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });O esquema e o tipo genérico devem permanecer sincronizados - Se você alterar o esquema, mas não o genérico do useForm (ou vice-versa), a validação e os tipos divergirão silenciosamente. Correção: Sempre derive o tipo do formulário com z.infer<typeof Schema>.
Campos opcionais com strings vazias - Inputs HTML submetem "" para campos vazios, mas z.string().optional() espera undefined. Correção: Use .optional().or(z.literal("")) ou pré-processe strings vazias para undefined.
Transforms não refletidos nos valores do formulário - zodResolver retorna os dados analisados (transformados) para onSubmit, mas os campos do formulário ainda mostram os valores brutos da entrada. Correção: Isso é esperado; transforms aplicam-se apenas aos dados passados para onSubmit.
Erros entre campos precisam de path - .refine() em nível de objeto sem path coloca o erro em errors.root, que é fácil de perder na UI. Correção: Sempre especifique path: ["fieldName"].
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| Resolver Yup | Código legado usando esquemas Yup | Começando do zero (Zod tem melhor inferência) |
| Resolver Valibot | Você precisa de tamanho mínimo de bundle | Você precisa do ecossistema Zod |
| Validação RHF embutida | Regras muito simples (obrigatório, minLength) apenas | Você deseja validação centralizada de esquema |
| Validação apenas no servidor | Formulários submetidos via server actions sem JS no cliente | Você precisa de feedback instantâneo em nível de campo |
O pacote @hookform/resolvers fornece zodResolver, que você passa para useForm({ resolver: zodResolver(Schema) }).
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });schema.safeParse(values) com todos os valores dos campos.ZodError.issues para o formato FieldErrors do RHF.errors.fieldName.Use .optional().or(z.literal("")) no campo do esquema. Sem isso, z.string().optional() espera undefined, mas inputs HTML enviam "".
const Schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: "As senhas devem coincidir",
path: ["confirmPassword"],
});Ele aciona a validação quando um campo perde o foco, em vez de apenas na submissão, fornecendo feedback aos usuários à medida que eles se movem entre os campos.
Ele fica em errors.root, que é fácil de perder na UI. Sempre especifique path: ["fieldName"] para anexar o erro ao campo correto.
A validação e os tipos divergem silenciosamente. O resolver valida contra a forma do esquema, mas register() é verificado em tempo de compilação contra o genérico. É por isso que derivar o tipo do esquema é crucial.
Não. zodResolver retorna os dados transformados apenas para o manipulador onSubmit. Os campos do formulário ainda exibem os valores brutos da entrada. Este é o comportamento esperado.
const Schema = z.object({
username: z.string().refine(async (val) => {
return await checkUsername(val);
}, "Nome de usuário já em uso"),
});
// zodResolver lida com assíncrono automaticamente
useForm({ resolver: zodResolver(Schema) });useForm({
resolver: zodResolver(Schema, {
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});z.infer<typeof Schema> está sempre em sincronia com a definição do esquema.Não. O TypeScript não sinaliza uma incompatibilidade no nível do resolver. A segurança de tipo vem de register("fieldName") ser verificado contra o genérico. É por isso que derivar o tipo do esquema é crítico.
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥