RHF + Zod
Integra esquemas de Zod con react-hook-form mediante @hookform/resolvers/zod para formularios totalmente tipados e impulsados por esquema.
Receta
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.
Ejemplo Funcional
"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:
- Cableado de
zodResolverauseForm - Validación entre campos con
.refine()a nivel de objeto con orientación de path - Campos opcionales que aceptan strings vacíos (
.or(z.literal(""))) mode: "onBlur"para comportamiento de validación al perder el foco- Seguimiento de cambios y gestión del estado de envío
Profundización
Cómo funciona
zodResolver(schema)devuelve una función que coincide con el tipoResolverde RHF- En el envío del formulario (o validación activada por modo), RHF llama al resolver con todos los valores de los campos
- El resolver ejecuta
schema.safeParse(values)y mapeaZodError.issuesal formatoFieldErrorsde RHF - RHF luego distribuye los errores a los campos correctos mediante
errors.fieldName - Los errores de
.refine()a nivel de esquema se mapean alpathque especifiques, o arootsi no se proporciona ruta
Variaciones
Acceder 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) });Notas de TypeScript
// 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) });Trampas
-
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 conz.infer<typeof Schema>. -
Campos opcionales con strings vacíos — Los inputs HTML envían
""para campos vacíos, peroz.string().optional()esperaundefined. Solución: Usa.optional().or(z.literal(""))o preprocesa strings vacíos aundefined. -
Transformaciones no reflejadas en valores del formulario —
zodResolverdevuelve los datos analizados (transformados) aonSubmit, 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 aonSubmit. -
Los errores entre campos necesitan
path—.refine()a nivel de objeto sinpathcoloca el error enerrors.root, lo que es fácil de perder en la UI. Solución: Siempre especificapath: ["fieldName"].
Alternativas
| 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 |
Preguntas frecuentes
¿Qué paquete conecta esquemas de Zod a react-hook-form?
El paquete @hookform/resolvers proporciona zodResolver, que pasas a useForm({ resolver: zodResolver(Schema) }).
¿Cómo derives el tipo TypeScript del formulario desde un esquema de Zod?
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });¿Qué hace zodResolver internamente cuando se envía el formulario?
- Llama a
schema.safeParse(values)con todos los valores de los campos - En caso de fallo, mapea
ZodError.issuesal formatoFieldErrorsde RHF - RHF luego distribuye errores a los campos correctos mediante
errors.fieldName
¿Cómo manejas campos opcionales que los inputs HTML envían como strings vacíos?
Usa .optional().or(z.literal("")) en el campo del esquema. Sin esto, z.string().optional() espera undefined, pero los inputs HTML envían "".
¿Cómo validas que dos campos coincidan (por ejemplo, confirmación de contraseña)?
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"],
});¿Qué hace mode: "onBlur" en useForm?
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.
¿Dónde aparece un error de .refine() si omites la opción path?
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.
Trampa: ¿Qué pasa si tu esquema de Zod y el tipo genérico de useForm se desincronizar?
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.
Trampa: ¿Un .transform() de Zod cambia los valores mostrados en los campos del formulario?
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.
¿Cómo usas validación asincrónica (por ejemplo, verificar disponibilidad de nombre de usuario) con zodResolver?
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) });¿Cómo pasas un mapa de errores personalizado a zodResolver?
useForm({
resolver: zodResolver(Schema, {
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});TypeScript: ¿Por qué deberías evitar escribir manualmente una interfaz para datos del formulario?
- Las interfaces manuales pueden divergir del esquema, causando que la validación en tiempo de ejecución no esté de acuerdo con los tipos en tiempo de compilación
z.infer<typeof Schema>siempre está sincronizado con la definición del esquema- Elimina una clase completa de errores donde se agrega un campo a uno pero no al otro
TypeScript: ¿El resolver zodResolver en sí mismo detecta desajustes de tipos entre el esquema y el genérico de useForm?
No. 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.
Relacionado
- React Hook Form — Conceptos principales de RHF
- Zod Basics — Definición de esquema
- shadcn Form — Integración de shadcn + RHF + Zod
- Form Patterns Basic — Patrones de formulario comunes