Validación de Formularios con react-hook-form y Zod
Construí formularios type-safe en React con react-hook-form y Zod: campos anidados, reglas async, campos dinámicos e integración con UI.
Overview
react-hook-form mantiene los re-renders bajos registrando inputs no controlados en lugar de
trackear cada keystroke. zod te permite escribir un esquema de validación y sacarle un tipo de
TypeScript. Sumá @hookform/resolvers/zod y el formulario valida contra el esquema mientras los
valores salen ya tipados. El resultado: un formulario type-safe, rápido y con las reglas de
validación en un solo lugar.
Elegí este combo después de pelearme con Formik en un formulario de checkout con ~30 campos. Cada
keystroke re-rendereaba todo el árbol, y la lógica de validación estaba dispersa en useEffect
hooks. Cambiar a react-hook-form + Zod cortó los re-renders casi a cero y movió cada regla a un
único archivo de schema. Si ya usaste discriminated unions en TypeScript,
la API de Zod te va a resultar familiar — espeja el type system que ya usás.
Cuándo Usarlo
Este combo brilla cuando la validación se pone fea: reglas condicionales, cross-field, validaciones async del lado del servidor, objetos anidados o field arrays. También es la elección correcta para formularios grandes donde cada keystroke no debería disparar un re-render, y para integrar con librerías de UI como shadcn/ui, Material UI o Chakra. Para inputs que disparan rápido (search-as-you-type), combiná con un debounce o throttle para no golpear tus validadores async.
Cuándo NO Usarlo
Para un formulario con uno o dos campos, un poco de useState y un chequeo manual alcanzan. Si
necesitás feedback visual rico en cada keystroke, un componente totalmente controlado puede ser más
simple. Y obviamente, solo funciona en React.
Solución
Instalación
npm install react-hook-form @hookform/resolvers zod
Formulario básico con validación Zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.number().min(18, "Must be at least 18").max(120, "Invalid age"),
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label htmlFor="age">Age</label>
<input id="age" type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Sign up"}
</button>
</form>
);
}
Validación de objeto anidado
const schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
address: z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
zipCode: z.string().regex(/^\d{5}$/, "Must be 5 digits"),
}),
preferences: z.object({
newsletter: z.boolean().default(false),
notifications: z.enum(["all", "important", "none"]),
}),
});
type FormData = z.infer<typeof schema>;
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("firstName")} placeholder="First name" />
<input {...register("lastName")} placeholder="Last name" />
<input {...register("address.street")} placeholder="Street" />
{errors.address?.street && <span>{errors.address.street.message}</span>}
<input {...register("address.city")} placeholder="City" />
{errors.address?.city && <span>{errors.address.city.message}</span>}
<input {...register("address.zipCode")} placeholder="ZIP" />
{errors.address?.zipCode && <span>{errors.address.zipCode.message}</span>}
<select {...register("preferences.notifications")}>
<option value="all">All</option>
<option value="important">Important only</option>
<option value="none">None</option>
</select>
<label>
<input type="checkbox" {...register("preferences.newsletter")} />
Subscribe to newsletter
</label>
<button type="submit">Save</button>
</form>
);
}
Validación cross-field
const schema = z.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof schema>;
function PasswordForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register("confirmPassword")} placeholder="Confirm" />
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
<button type="submit">Set password</button>
</form>
);
}
Validación async
const schema = z.object({
username: z.string().min(3).refine(
async (username) => {
const res = await fetch(`/api/check-username?u=${username}`);
const { available } = await res.json();
return available;
},
"Username already taken"
),
email: z.string().email(),
});
function UsernameForm() {
const {
register,
handleSubmit,
formState: { errors, isValidating },
} = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("username")} placeholder="Username" />
{isValidating && <span>Checking...</span>}
{errors.username && <span>{errors.username.message}</span>}
<input {...register("email")} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">Register</button>
</form>
);
}
Field arrays (campos dinámicos)
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
items: z.array(
z.object({
name: z.string().min(1, "Item name required"),
quantity: z.number().min(1, "At least 1"),
price: z.number().min(0, "Must be positive"),
})
).min(1, "At least one item required"),
});
type FormData = z.infer<typeof schema>;
function InvoiceForm() {
const { register, control, handleSubmit, formState: { errors } } =
useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { items: [{ name: "", quantity: 1, price: 0 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} placeholder="Item name" />
<input type="number" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
<input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
{errors.items?.message && <span>{errors.items.message}</span>}
<button type="button" onClick={() => append({ name: "", quantity: 1, price: 0 })}>
Add item
</button>
<button type="submit">Submit</button>
</form>
);
}
Validación condicional
const schema = z.discriminatedUnion("type", [
z.object({
type: z.literal("individual"),
firstName: z.string().min(1),
lastName: z.string().min(1),
ssn: z.string().regex(/^\d{3}-\d{2}-\d{4}$/),
}),
z.object({
type: z.literal("business"),
companyName: z.string().min(1),
taxId: z.string().regex(/^\d{2}-\d{7}$/),
}),
]);
type FormData = z.infer<typeof schema>;
function RegistrationForm() {
const { register, handleSubmit, watch, formState: { errors } } =
useForm<FormData>({ resolver: zodResolver(schema) });
const type = watch("type");
return (
<form onSubmit={handleSubmit(console.log)}>
<select {...register("type")}>
<option value="individual">Individual</option>
<option value="business">Business</option>
</select>
{type === "individual" && (
<>
<input {...register("firstName")} placeholder="First name" />
<input {...register("lastName")} placeholder="Last name" />
<input {...register("ssn")} placeholder="SSN" />
</>
)}
{type === "business" && (
<>
<input {...register("companyName")} placeholder="Company name" />
<input {...register("taxId")} placeholder="Tax ID" />
</>
)}
<button type="submit">Register</button>
</form>
);
}
Integración con shadcn/ui
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Min 8 characters"),
});
type FormData = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit(console.log)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register("email")} />
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
<Button type="submit" className="w-full">Login</Button>
</form>
);
}
Variantes
Usar FormProvider para componentes anidados
import { FormProvider, useForm, useFormContext } from "react-hook-form";
function AddressFields() {
const { register, formState: { errors } } = useFormContext();
return (
<>
<input {...register("address.street")} placeholder="Street" />
{errors.address?.street && <span>{errors.address.street.message}</span>}
</>
);
}
function CheckoutForm() {
const methods = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(console.log)}>
<AddressFields />
<button type="submit">Submit</button>
</form>
</FormProvider>
);
}
Resetear formulario después del envío
function EditProfileForm({ defaultValues }: { defaultValues: FormData }) {
const { register, handleSubmit, reset } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues,
});
const onSubmit = async (data: FormData) => {
await saveProfile(data);
reset(data); // Resetear a los nuevos valores guardados
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* fields */}
<button type="submit">Save</button>
<button type="button" onClick={() => reset()}>Reset</button>
</form>
);
}
Explicación
El insight clave acá: react-hook-form es no controlado por defecto. register() esparce props
sobre un input nativo, y el formulario lee los valores en submit — sin useState por campo, sin
re-render por keystroke. Zod maneja la validación como única fuente de verdad. Escribís el schema
una vez, derivás el tipo con z.infer, y el resolver conecta las dos cosas.
El flujo de arriba es todo el modelo mental: schema → resolver → form → output tipado. No hay una
definición de tipo separada ni una config de validación separada — Zod maneja ambas. Si usaste
utility types y generics antes, el patrón
z.infer es la misma idea: derivar tipos de una única declaración en vez de mantener dos
definiciones paralelas.
Una cosa a tener en cuenta: useFieldArray y watch sí disparan re-renders por diseño. Es
esperable — son opt-in, así que solo los pagás cuando los necesitás. El path default (register
- submit) sigue siendo no controlado.
Buenas Prácticas
Derivá el tipo del formulario del esquema Zod con z.infer<typeof schema> en vez de definirlo dos
veces. Para inputs numéricos, agregá valueAsNumber: true para que el valor parseado sea realmente
un número y no un string. Seteá defaultValues para cada campo; react-hook-form se comporta mejor
con valores iniciales, especialmente para checkboxes y selects.
Si querés feedback antes de que el usuario envíe, cambiá a mode: "onBlur" o mode: "onChange".
Para campos repetidos o dinámicos, useFieldArray es mucho más seguro que manejar índices a mano.
Cuando
el formulario se divide en componentes hijos, usá FormProvider para evitar prop drilling. Mantené
las reglas de validación dentro del esquema Zod en lugar de dispersarlas en el componente. Para
tips de performance, consultá When to Use useMemo and
useCallback.
Errores Comunes
- Olvidar
valueAsNumber. Los inputs numéricos retornan strings por defecto, así quez.number()los rechaza a menos que agreguesvalueAsNumber: true. - Saltear
defaultValues. Sin ellos, checkboxes y selects pueden quedarundefined. - Validar solo en submit. Nadie quiere llenar un formulario largo, enviarlo y descubrir que
la mitad de los campos estaban mal.
mode: "onBlur"lo resuelve — la validación corre cuando el usuario sale de cada campo. - No tipar el resolver. Siempre pasá el genérico
useForm<FormData>({ resolver: zodResolver(schema) })para obtener valores tipados. - Usar controlled components innecesariamente.
registerdevuelve props para inputs no controlados, así que no pongas tu propiovalueyonChangeencima.
See Also
- Docs de react-hook-form — referencia de la API para
useForm,useFieldArray,ControlleryFormProvider. Vale la pena bookmark — los ejemplos cubren edge cases que el README no. - Docs de Zod — declaración de schemas,
z.infer,refine,discriminatedUniony customización de errores. Empezá por el README, después revisá la API. - @hookform/resolvers — paquete adaptador que conecta Zod, Yup, Valibot y otros con react-hook-form. Revisá la matriz de versiones soportadas.
- shadcn/ui Form — envuelve react-hook-form con primitivas de Radix y estilos Tailwind. Buen reference para integrar ambos en una app real.
Preguntas frecuentes
¿Cómo valido en change en lugar de en submit?
useForm<FormData>({
resolver: zodResolver(schema),
mode: "onChange",
});
¿Puedo usar react-hook-form sin Zod?
Sí. Para casos simples podés pasar las reglas directamente a register, como register("name", { required: true, minLength: 2 }). Zod empieza a valer la pena cuando el esquema crece o querés
reutilizarlo en varios lugares.
¿Cómo seteo un valor de campo programáticamente?
const { setValue } = useForm<FormData>();
setValue("email", "test@example.com", { shouldValidate: true });
¿Cómo observo un valor de campo?
const email = watch("email");
// o
const { watch } = useForm<FormData>();
const subscription = watch((value) => console.log(value));
¿Puedo usar react-hook-form con React Native?
Sí, pero necesitás Controller en vez de register — el TextInput de React Native no expone
una ref plana, así que el approach usual de spread no funciona:
import { Controller } from "react-hook-form";
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<TextInput onChangeText={onChange} value={value} />
)}
/>
¿Por qué mis campos numéricos aparecen como strings?
Los inputs numéricos retornan strings en el DOM. Si tu schema Zod usa z.number(), la validación
falla a menos que agregues valueAsNumber: true a register:
<input type="number" {...register("age", { valueAsNumber: true })} />
Es el gotcha más común que veo en code reviews.
¿Cuál es la diferencia entre watch y useWatch?
watch suscribe a nivel form y re-renderea todo el componente en cada cambio. useWatch acota la
suscripción a un campo o array específico, así que solo el componente que necesita el valor
re-renderea. Para formularios grandes, useWatch es casi siempre la mejor opción.
Recursos Relacionados
Cuándo Usar useMemo y useCallback
Cómo y cuándo usar los hooks useMemo y useCallback de React para optimización de performance, y cuándo añaden overhead innecesario.
RecipeVirtualizar Listas Largas con react-window
Cómo renderizar listas grandes eficientemente en React usando react-window para virtualización del DOM, incluyendo filas de altura fija y variable y layouts de grid.
RecipeType Checking Exhaustivo con Discriminated Unions
Cómo usar discriminated unions de TypeScript para type checking exhaustivo, asegurando que todos los casos se manejen en compile time con aserciones de tipo never.
RecipeConstruir Utility Types Reutilizables con Generics
Cómo crear utility types reutilizables en TypeScript usando conditional types, mapped types, template literals y generic constraints para APIs type-safe.
RecipeFunciones Debounce y Throttle en JavaScript
Controla la tasa de ejecución de funciones con debounce y throttle. Cubre leading y trailing edge, timers cancelables y casos de uso reales.
RecipeRenderizado en el servidor (SSR)
Mejora el rendimiento y el SEO con renderizado en el servidor usando Next.js, Nuxt, Astro y otros frameworks con estrategias de hidratación.