StackPractices
intermediate Por Mathias Paulenko

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.

Temas: frontend

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.

flowchart diagram: Zod schema

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í que z.number() los rechaza a menos que agregues valueAsNumber: true.
  • Saltear defaultValues. Sin ellos, checkboxes y selects pueden quedar undefined.
  • 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. register devuelve props para inputs no controlados, así que no pongas tu propio value y onChange encima.

See Also

  • Docs de react-hook-form — referencia de la API para useForm, useFieldArray, Controller y FormProvider. Vale la pena bookmark — los ejemplos cubren edge cases que el README no.
  • Docs de Zod — declaración de schemas, z.infer, refine, discriminatedUnion y 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.