React Hook Form
Formularios eficientes y flexibles con re-renderizados mínimos — setup, register, Controller, y patrones principales.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
"use client";
import { useForm, Controller } from "react-hook-form";
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
defaultValues: { email: "", password: "", rememberMe: false },
});
async function onSubmit(data: LoginForm) {
await fetch("/api/login", { method: "POST", body: JSON.stringify(data) });
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register — para inputs nativos */}
<input {...register("email", { required: "Email es requerido" })} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password", { minLength: 8 })} />
{/* Controller — para componentes controlados */}
<Controller
name="rememberMe"
control={control}
render={({ field }) => (
<label>
<input type="checkbox" checked={field.value} onChange={field.onChange} />
Recuérdame
</label>
)}
/>
<button type="submit" disabled={isSubmitting}>Iniciar sesión</button>
</form>
);
}Cuándo usarlo: Cuando necesitas un formulario con validación, manejo de errores y buen rendimiento — especialmente cuando el formulario tiene más de un par de campos.
Ejemplo Funcional
"use client";
import { useForm, useFieldArray } from "react-hook-form";
interface Ingredient {
name: string;
amount: string;
}
interface RecipeFormData {
title: string;
description: string;
servings: number;
ingredients: Ingredient[];
}
export function RecipeEditor() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting, isDirty },
reset,
watch,
} = useForm<RecipeFormData>({
defaultValues: {
title: "",
description: "",
servings: 4,
ingredients: [{ name: "", amount: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "ingredients",
});
const watchTitle = watch("title");
function onSubmit(data: RecipeFormData) {
console.log("Recipe:", data);
reset(data); // limpia isDirty
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
<h2 className="text-lg font-bold">
{watchTitle || "Nueva Receta"}
</h2>
<div>
<input
{...register("title", { required: "El título es requerido" })}
placeholder="Título de la receta"
className="w-full rounded border p-2"
/>
{errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
</div>
<textarea
{...register("description")}
placeholder="Descripción"
className="w-full rounded border p-2"
rows={3}
/>
<input
type="number"
{...register("servings", { valueAsNumber: true, min: 1 })}
className="w-32 rounded border p-2"
/>
<fieldset className="space-y-2">
<legend className="font-semibold">Ingredientes</legend>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
{...register(`ingredients.${index}.name`, { required: true })}
placeholder="Ingrediente"
className="flex-1 rounded border p-2"
/>
<input
{...register(`ingredients.${index}.amount`)}
placeholder="Cantidad"
className="w-24 rounded border p-2"
/>
<button type="button" onClick={() => remove(index)} className="text-red-500">
Eliminar
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: "", amount: "" })}
className="text-sm text-blue-600"
>
+ Agregar ingrediente
</button>
</fieldset>
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Guardar
</button>
{isDirty && <span className="self-center text-sm text-amber-600">Cambios sin guardar</span>}
</div>
</form>
);
}Lo que esto demuestra:
useFormcondefaultValuestipadosregisterpara inputs nativos con reglas de validaciónuseFieldArraypara listas dinámicaswatchpara observación de campos en tiempo real- Seguimiento de
isDirtyyresetdespués de guardar
Análisis Profundo
Cómo Funciona
- RHF usa inputs no controlados por defecto —
registervincularef,onChange,onBlur, ynameal input - El estado del formulario vive en un ref, no en React state — solo las partes a las que te suscriba causan re-renderizados
handleSubmitejecuta validación primero, luego llama a tuonSubmitsolo si es válido- Las propiedades de
formState(errors,isDirty,isValid, etc.) se evalúan con pereza mediante Proxy — solo las propiedades accedidas provocan re-renderizados Controllerconecta componentes controlados (selects personalizados, date pickers) en RHF
Variaciones
Modos de validación:
const { register } = useForm({
mode: "onBlur", // validar al perder foco (por defecto: "onSubmit")
reValidateMode: "onChange", // re-validar al cambiar después del primer error
});Establecer valores programáticamente:
const { setValue, getValues, trigger } = useForm();
// Establecer un campo individual
setValue("email", "new@example.com", { shouldValidate: true });
// Obtener todos los valores
const all = getValues();
// Activar validación manualmente
await trigger("email"); // campo individual
await trigger(); // todos los camposValores por defecto a nivel de formulario desde datos asíncronos:
const { reset } = useForm<ProfileForm>({
defaultValues: async () => {
const res = await fetch("/api/profile");
return res.json();
},
});Notas de TypeScript
// register fuertemente tipado — captura errores tipográficos en tiempo de compilación
register("emial"); // Error de TS: "emial" no es una clave de LoginForm
// Errores tipados
errors.email?.message; // string | undefined
// Tipo UseFormReturn para pasar métodos de formulario como props
import type { UseFormReturn } from "react-hook-form";
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
return <input {...form.register("email")} />;
}
// FieldPath para componentes de campo genéricos
import type { FieldPath, FieldValues } from "react-hook-form";
function TextInput<T extends FieldValues>({
name,
control,
}: {
name: FieldPath<T>;
control: Control<T>;
}) {
return <Controller name={name} control={control} render={({ field }) => <input {...field} />} />;
}Trampa Potencial
-
Los valores por defecto deben estar completos — RHF usa
defaultValuespara determinar el estadoisDirtyinicial. Omitir campos lleva a un seguimiento de dirty incorrecto. Solución: Siempre proporciona cada campo endefaultValues. -
registerdevuelve unref— No sobrescriba elrefdevuelto porregister. Solución: UsaControllersi necesitas un ref personalizado, o fusiona refs con un callback ref. -
Re-renderizados desde formState — Desestructurar
formStateen el nivel superior se suscribe a todas las propiedades. Solución: Solo desestructura las propiedades que necesitas:const { errors } = formState. -
valueAsNumberdevuelveNaNpara inputs vacíos — Si el input está vacío,valueAsNumber: truedaNaN. Solución: Combina consetValueAso usa coerción de Zod mediante un resolver. -
Claves de
useFieldArray— Siempre usafield.idcomokey, no el índice del array. Usar el índice causa bugs de estado al reordenar o eliminar elementos.
Alternativas
| Alternativa | Usarlo Cuando | No Usarlo Cuando |
|---|---|---|
| FormData Nativo | Formularios de Server Action simples con lógica mínima del cliente | Necesitas validación a nivel de campo y campos dinámicos |
| Formik | Estás en una base de código heredada que ya usa Formik | Iniciando un nuevo proyecto (RHF tiene mejor rendimiento) |
React 19 useActionState | Formularios orientados al servidor sin JS del lado del cliente | Necesitas validación instantánea de campos y UX complejo |
| Tanstack Form | Quieres lógica de formulario agnóstica del framework | Necesitas el ecosistema más amplio de resolvers y librerías de UI |
Preguntas Frecuentes
¿Cuál es la diferencia entre register y Controller?
registerfunciona con inputs HTML nativos vinculandoref,onChange,onBlur, ynameControllerenvuelve componentes controlados (selects personalizados, date pickers) que necesitan propsvalueyonChange- Usa
registerpara inputs nativos; usaControllerpara componentes de terceros o personalizados
¿Cómo logra react-hook-form re-renderizados mínimos?
- El estado del formulario vive en un ref, no en React state, así que las actualizaciones no provocan re-renderizados por defecto
- Las propiedades de
formStatese evalúan con pereza mediante Proxy -- solo las propiedades accedidas provocan re-renderizados - Solo desestructura lo que necesitas:
const { errors } = formStateen lugar del objeto completo
¿Qué rastrea isDirty y cómo lo reinician?
isDirtyestruecuando cualquier valor de campo difiere de sudefaultValues- Llama a
reset(data)después de un guardado exitoso para actualizar la línea de base y establecerisDirtyafalse - Omitir campos de
defaultValueslleva a un seguimiento de dirty incorrecto
¿Cómo carga valores por defecto desde una fuente de datos asíncrona?
const { reset } = useForm<ProfileForm>({
defaultValues: async () => {
const res = await fetch("/api/profile");
return res.json();
},
});- Pasa una función asíncrona a
defaultValuesy RHF la resuelve automáticamente
¿Qué proporciona useFieldArray y cuándo deberías usarlo?
- Gestiona arrays de objetos con
append,remove,insert,move,swap,replace, yupdate - Cada elemento obtiene un
field.idestable para usar como una React key - Úsalo para listas dinámicas como ingredientes, elementos de línea o direcciones
Trampa: ¿Por qué nunca deberías sobrescribir el ref devuelto por register?
registerdevuelve unrefque RHF usa para rastrear el elemento input- Sobrescribirlo desconecta RHF del elemento DOM
- Solución: usa
Controllersi necesitas un ref personalizado, o fusiona refs con un callback ref
Trampa: ¿Por qué valueAsNumber devuelve NaN para inputs vacíos?
- Cuando el input está vacío,
Number("")devuelveNaN valueAsNumber: trueusa esta conversión, resultando enNaNen los datos del formulario- Solución: combina con
setValueAso usaz.coerce.number()de Zod mediante un resolver en su lugar
¿Cómo activas validación manualmente para un solo campo en TypeScript?
const { trigger } = useForm<FormData>();
await trigger("email"); // valida solo el campo email
await trigger(); // valida todos los campostriggeracepta nombres de campo tipados del parámetro genérico del formulario
¿Cómo pasas métodos de formulario a componentes secundarios con tipos de TypeScript adecuados?
import type { UseFormReturn } from "react-hook-form";
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
return <input {...form.register("email")} />;
}- Usa
UseFormReturn<T>como tipo de prop para obtener seguridad de tipo completa
¿Qué modos de validación están disponibles y cómo difieren?
"onSubmit"(por defecto): valida solo cuando se envía el formulario"onBlur": valida cuando un campo pierde el foco"onChange": valida en cada pulsación de teclareValidateModecontrola el comportamiento de re-validación después del primer error
¿Cómo difiere watch de getValues?
watch("title")se suscribe a cambios y provoca re-renderizados cuando el valor cambiagetValues("title")lee el valor actual sin suscribirse a cambios- Usa
watchpara actualizaciones de UI reactivas; usagetValuespara lecturas únicas
Relacionado
- RHF + Zod — Integración del resolver de Zod
- Patrones de Formulario Básico — login, signup, contacto
- Patrones de Formulario Complejo — multi-paso, campos dinámicos
- shadcn Form — Integración del componente shadcn Form