React Hook Form
Formularios eficientes y flexibles con re-renderizados mínimos - setup, register, Controller, y patrones principales.
Busca en todas las páginas de la documentación
Formularios eficientes y flexibles con re-renderizados mínimos - setup, register, Controller, y patrones principales.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
useForm con defaultValues tipadosregister para inputs nativos con reglas de validaciónuseFieldArray para listas dinámicaswatch para observación de campos en tiempo realisDirty y reset después de guardarregister vincula ref, onChange, onBlur, y name al inputhandleSubmit ejecuta validación primero, luego llama a tu onSubmit solo si es válidoformState (errors, isDirty, isValid, etc.) se evalúan con pereza mediante Proxy - solo las propiedades accedidas provocan re-renderizadosController conecta componentes controlados (selects personalizados, date pickers) en RHFModos 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();
},
});// 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} />} />;
}Los valores por defecto deben estar completos - RHF usa defaultValues para determinar el estado isDirty inicial. Omitir campos lleva a un seguimiento de dirty incorrecto. Solución: Siempre proporciona cada campo en defaultValues.
register devuelve un ref - No sobrescriba el ref devuelto por register. Solución: Usa Controller si necesitas un ref personalizado, o fusiona refs con un callback ref.
Re-renderizados desde formState - Desestructurar formState en el nivel superior se suscribe a todas las propiedades. Solución: Solo desestructura las propiedades que necesitas: const { errors } = formState.
valueAsNumber devuelve NaN para inputs vacíos - Si el input está vacío, valueAsNumber: true da NaN. Solución: Combina con setValueAs o usa coerción de Zod mediante un resolver.
Claves de useFieldArray - Siempre usa field.id como key, no el índice del array. Usar el índice causa bugs de estado al reordenar o eliminar elementos.
| 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 |
register funciona con inputs HTML nativos vinculando ref, onChange, onBlur, y nameController envuelve componentes controlados (selects personalizados, date pickers) que necesitan props value y onChangeregister para inputs nativos; usa Controller para componentes de terceros o personalizadosformState se evalúan con pereza mediante Proxy -- solo las propiedades accedidas provocan re-renderizadosconst { errors } = formState en lugar del objeto completoisDirty es true cuando cualquier valor de campo difiere de su defaultValuesreset(data) después de un guardado exitoso para actualizar la línea de base y establecer isDirty a falsedefaultValues lleva a un seguimiento de dirty incorrectoconst { reset } = useForm<ProfileForm>({
defaultValues: async () => {
const res = await fetch("/api/profile");
return res.json();
},
});defaultValues y RHF la resuelve automáticamenteappend, remove, insert, move, swap, replace, y updatefield.id estable para usar como una React keyregister devuelve un ref que RHF usa para rastrear el elemento inputController si necesitas un ref personalizado, o fusiona refs con un callback refNumber("") devuelve NaNvalueAsNumber: true usa esta conversión, resultando en NaN en los datos del formulariosetValueAs o usa z.coerce.number() de Zod mediante un resolver en su lugarconst { trigger } = useForm<FormData>();
await trigger("email"); // valida solo el campo email
await trigger(); // valida todos los campostrigger acepta nombres de campo tipados del parámetro genérico del formularioimport type { UseFormReturn } from "react-hook-form";
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
return <input {...form.register("email")} />;
}UseFormReturn<T> como tipo de prop para obtener seguridad de tipo completa"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 teclareValidateMode controla el comportamiento de re-validación después del primer errorwatch("title") se suscribe a cambios y provoca re-renderizados cuando el valor cambiagetValues("title") lee el valor actual sin suscribirse a cambioswatch para actualizaciones de UI reactivas; usa getValues para lecturas únicasRevisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥