RHF + Zod
Integra esquemas de Zod con react-hook-form mediante @hookform/resolvers/zod para formularios totalmente tipados e impulsados por esquema.
Busca en todas las páginas de la documentación
Integra esquemas de Zod con react-hook-form mediante @hookform/resolvers/zod para formularios totalmente tipados e impulsados por esquema.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
"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("Correo inválido"),
password: z.string().min(8, "Al menos 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">Inicia sesión</button>
</form>
);
}Cuándo usarlo: Cuando deseas que reglas de validación definidas por esquema (Zod) impulsen la visualización de errores a nivel de campo de 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, "Al menos 3 caracteres")
.max(20)
.regex(/^[a-z0-9_]+$/, "Solo letras minúsculas, números y guiones bajos"),
displayName: z.string().min(1, "Requerido"),
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: "Las contraseñas deben 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 una API
console.log("Guardado:", data);
reset(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
<div>
<label className="block text-sm font-medium">Nombre de usuario</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">Nombre para mostrar</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">Biografía</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">Sitio web</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">Cambiar contraseña (opcional)</legend>
<div className="space-y-2">
<input {...register("newPassword")} type="password" placeholder="Nueva contraseña" 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 ? "Guardando..." : "Guardar perfil"}
</button>
</form>
);
}Lo que esto demuestra:
zodResolver a useForm.refine() a nivel de objeto con orientación de path.or(z.literal("")))mode: "onBlur" para comportamiento de validación al perder el focozodResolver(schema) devuelve una función que coincide con el tipo Resolver de RHFschema.safeParse(values) y mapea ZodError.issues al formato FieldErrors de RHFerrors.fieldName.refine() a nivel de esquema se mapean al path que especifiques, o a root si no se proporciona rutaAcceder a errores de nivel raíz:
const { formState: { errors } } = useForm({ resolver: zodResolver(schema) });
// Error raíz de un .refine() sin ruta
errors.root?.message;Resolver con mapeo de errores personalizado:
useForm({
resolver: zodResolver(Schema, {
// Pasar opciones de Zod
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});Esquema con validación asincrónica:
const Schema = z.object({
username: z.string().refine(async (val) => {
const available = await checkUsername(val);
return available;
}, "Nombre de usuario ya existe"),
});
// zodResolver maneja async automáticamente
useForm({ resolver: zodResolver(Schema) });// El tipo genérico fluye del esquema
const Schema = z.object({ name: z.string() });
type T = z.infer<typeof Schema>;
// useForm se tipifica por el genérico, no por el resolver
const form = useForm<T>({ resolver: zodResolver(Schema) });
// Si los tipos no coinciden entre z.infer y el genérico, TS lo detecta
const BadSchema = z.object({ email: z.string() });
// useForm<T>({ resolver: zodResolver(BadSchema) }) - sin error de TS a nivel de resolver
// pero register("name") seguirá siendo type-safe contra T
// Mejor práctica: derivar el tipo del esquema
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });Esquema y tipo genérico deben mantenerse sincronizados - Si cambias el esquema pero no el genérico de useForm (o viceversa), la validación y los tipos divergen silenciosamente. Solución: Siempre deriva el tipo del formulario con z.infer<typeof Schema>.
Campos opcionales con strings vacíos - Los inputs HTML envían "" para campos vacíos, pero z.string().optional() espera undefined. Solución: Usa .optional().or(z.literal("")) o preprocesa strings vacíos a undefined.
Transformaciones no reflejadas en valores del formulario - zodResolver devuelve los datos analizados (transformados) a onSubmit, pero los campos del formulario aún muestran los valores de entrada sin procesar. Solución: Esto es esperado; las transformaciones se aplican solo a los datos pasados a onSubmit.
Los errores entre campos necesitan path - .refine() a nivel de objeto sin path coloca el error en errors.root, lo que es fácil de perder en la UI. Solución: Siempre especifica path: ["fieldName"].
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| Resolutor Yup | Base de código heredada que usa esquemas Yup | Comenzando desde cero (Zod tiene mejor inferencia) |
| Resolutor Valibot | Necesitas tamaño de bundle mínimo | Necesitas el ecosistema de Zod |
| Validación incorporada de RHF | Solo reglas muy simples (required, minLength) | Deseas validación de esquema centralizada |
| Validación solo en servidor | Los formularios se envían mediante acciones del servidor sin JS en el cliente | Necesitas retroalimentación instantánea a nivel de campo |
El paquete @hookform/resolvers proporciona zodResolver, que pasas a 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) con todos los valores de los camposZodError.issues al formato FieldErrors de RHFerrors.fieldNameUsa .optional().or(z.literal("")) en el campo del esquema. Sin esto, z.string().optional() espera undefined, pero los inputs HTML envían "".
const Schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: "Las contraseñas deben coincidir",
path: ["confirmPassword"],
});Activa la validación cuando un campo pierde el foco en lugar de solo en el envío, dando a los usuarios retroalimentación mientras se mueven entre campos.
Cae en errors.root, lo que es fácil de perder en la UI. Siempre especifica path: ["fieldName"] para adjuntar el error al campo correcto.
La validación y los tipos divergen silenciosamente. El resolver valida contra la forma del esquema, pero register() se verifica contra el genérico. Siempre deriva el tipo con z.infer<typeof Schema> para mantenerlos sincronizados.
No. zodResolver devuelve los datos transformados solo al manejador onSubmit. Los campos del formulario aún muestran los valores de entrada sin procesar. Este es el comportamiento esperado.
const Schema = z.object({
username: z.string().refine(async (val) => {
return await checkUsername(val);
}, "Nombre de usuario ya existe"),
});
// zodResolver maneja async automáticamente
useForm({ resolver: zodResolver(Schema) });useForm({
resolver: zodResolver(Schema, {
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});z.infer<typeof Schema> siempre está sincronizado con la definición del esquemaNo. TypeScript no marca un desajuste a nivel de resolver. La seguridad de tipos proviene de register("fieldName") siendo verificado contra el genérico. Por eso derivar el tipo del esquema es crítico.
Revisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥