React Hook Form handles React forms with uncontrolled inputs: call useForm, spread register("name") onto each input, and wrap your submit function in handleSubmit. Add zodResolver to validate with a Zod schema. The forms below run on this page: rules, Zod, async checks, field arrays, a wizard, file upload and server actions.
| Need | Command |
|---|---|
| The library | npm install react-hook-form |
| Schema validation | npm install zod @hookform/resolvers |
Updated
Fill in each demo, submit it with mistakes, then fix them. The code beside it is what runs. Errors are linked to their inputs with aria-describedby and announced with role="alert", so the forms work with a screen reader too.
Need a plain HTML form instead? See how to add a contact form.
| API | Use it for |
|---|---|
| register(name, rules) | Native inputs, selects, textareas and checkboxes |
| handleSubmit(onValid, onInvalid?) | Validates, then calls your function with the values |
| formState.errors / isSubmitting / isDirty | Error messages and button states |
| mode: onSubmit | onBlur | onTouched | onChange | all | When validation runs (onSubmit is the default) |
| resolver: zodResolver(schema) | Validate with a Zod (or Yup, Valibot) schema |
| useFieldArray | Repeating rows: add, remove, move |
| Controller / useController | Custom inputs without a ref |
| trigger(names) | Validate some fields now, as in a wizard step |
| setError / clearErrors | Errors that come back from the server |
| reset(values) | Load saved data or clear after submit |
| watch / useWatch | Read a value as it changes (useWatch re-renders less) |
register connects an input by name and takes HTML-style rules. errors holds a message per field after a failed submit.
import { useForm } from "react-hook-form";
export function Contact() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
const onSubmit = async (data) => { await sendMessage(data); };
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="name">Name</label>
<input id="name" {...register("name", { required: "Please enter your name" })}
aria-invalid={errors.name ? "true" : "false"} aria-describedby="name-err" />
{errors.name && <p id="name-err" role="alert">{errors.name.message}</p>}
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email", {
required: "Please enter your email",
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "That email looks wrong" },
})} aria-invalid={errors.email ? "true" : "false"} aria-describedby="email-err" />
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
<button disabled={isSubmitting}>Send</button>
</form>
);
}One schema gives you validation rules, error messages and the TypeScript type. refine checks two fields together, and path says which field gets the error.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
confirm: z.string(),
}).refine((v) => v.password === v.confirm, { message: "Passwords do not match", path: ["confirm"] });
type SignUp = z.infer<typeof schema>;
export function SignUpForm() {
const { register, handleSubmit, formState: { errors } } = useForm<SignUp>({
resolver: zodResolver(schema),
mode: "onTouched",
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input type="email" {...register("email")} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register("password")} aria-invalid={!!errors.password} />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="password" {...register("confirm")} aria-invalid={!!errors.confirm} />
{errors.confirm && <p role="alert">{errors.confirm.message}</p>}
<button>Create account</button>
</form>
);
}A validate function may return a promise. Return true when the value is fine, or a string to show as the error. Pair it with mode onBlur so it does not run on every key.
const { register, formState: { errors, isValidating } } = useForm({ mode: "onBlur" });
<input {...register("username", {
required: "Pick a username",
validate: async (value) => {
const res = await fetch(`/api/username-free?u=${encodeURIComponent(value)}`);
const { free } = await res.json();
return free || "That username is taken";
},
})} />
{isValidating && <p>Checking...</p>}
{errors.username && <p role="alert">{errors.username.message}</p>}useFieldArray manages a list inside the form. Use field.id as the key, never the index, so React keeps each input attached to the right row.
import { useFieldArray, useForm } from "react-hook-form";
export function Invoice() {
const { register, control, handleSubmit } = useForm({
defaultValues: { items: [{ name: "Logo design", qty: 1 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, i) => (
<div key={field.id}>
<input aria-label={`Item ${i + 1} name`} {...register(`items.${i}.name`, { required: true })} />
<input aria-label={`Item ${i + 1} quantity`} type="number"
{...register(`items.${i}.qty`, { valueAsNumber: true, min: 1 })} />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", qty: 1 })}>Add item</button>
<button>Save</button>
</form>
);
}Keep one form for every step and call trigger with the current step's fields before moving on. Values from earlier steps stay in the form, so the last submit has everything.
const steps = [["name", "email"], ["company", "size"], ["plan"]];
export function Wizard() {
const [step, setStep] = useState(0);
const { register, trigger, handleSubmit } = useForm({ shouldUnregister: false });
async function next() {
const ok = await trigger(steps[step]);
if (ok) setStep((s) => s + 1);
}
return (
<form onSubmit={handleSubmit(save)}>
<p>Step {step + 1} of {steps.length}</p>
{step === 0 && (<><input {...register("name", { required: true })} /><input {...register("email", { required: true })} /></>)}
{step === 1 && (<><input {...register("company", { required: true })} /><select {...register("size")}>...</select></>)}
{step === 2 && (<select {...register("plan", { required: true })}>...</select>)}
{step > 0 && <button type="button" onClick={() => setStep(step - 1)}>Back</button>}
{step < steps.length - 1 ? <button type="button" onClick={next}>Next</button> : <button>Finish</button>}
</form>
);
}A file input registers like any other; its value is a FileList. Check size and type in validate and send the file with FormData.
<input type="file" accept="image/png,image/jpeg"
{...register("logo", {
validate: {
present: (files) => files?.length > 0 || "Choose a file",
size: (files) => files?.[0]?.size <= 2 * 1024 * 1024 || "Max 2 MB",
type: (files) => ["image/png", "image/jpeg"].includes(files?.[0]?.type) || "PNG or JPG only",
},
})} />
// On submit
const onSubmit = async ({ logo }) => {
const body = new FormData();
body.append("logo", logo[0]);
await fetch("/api/upload", { method: "POST", body });
};Inputs that do not expose a ref, such as many date pickers and custom selects, connect through Controller. It passes value and onChange to your component.
import { Controller, useForm } from "react-hook-form";
const { control } = useForm({ defaultValues: { budget: 50 } });
<Controller
name="budget"
control={control}
rules={{ min: { value: 10, message: "At least 10" } }}
render={({ field, fieldState }) => (
<>
<MySlider value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} />
{fieldState.error && <p role="alert">{fieldState.error.message}</p>}
</>
)}
/>Validate in the browser for quick feedback and again on the server because the browser can be bypassed. Sharing one Zod schema keeps both in step; setError shows server-side problems on the right field.
// app/signup/schema.ts
import { z } from "zod";
export const schema = z.object({ email: z.string().email(), company: z.string().min(2) });
// app/signup/actions.ts
"use server";
import { schema } from "./schema";
export async function signup(input: unknown) {
const parsed = schema.safeParse(input);
if (!parsed.success) return { ok: false, field: "email", message: "Check your details" } as const;
if (await emailTaken(parsed.data.email)) return { ok: false, field: "email", message: "Already registered" } as const;
await createAccount(parsed.data);
return { ok: true } as const;
}
// app/signup/form.tsx
"use client";
export function SignupForm() {
const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) });
const onSubmit = form.handleSubmit(async (values) => {
const res = await signup(values);
if (!res.ok) form.setError(res.field, { message: res.message });
});
return <form onSubmit={onSubmit}>{/* fields */}</form>;
}
Every input needs a label that is linked with htmlFor, not only a placeholder. When a field is wrong, set aria-invalid on the input and point aria-describedby at the message, so screen readers read the error with the field.
On submit, React Hook Form focuses the first invalid field by default (shouldFocusError). Keep it on; it is the quickest way for a keyboard user to find what went wrong.
Using index as the key in a field array, which mixes up rows after a remove.
Calling watch() at the top of a large form, which re-renders the whole form on every key press. Use useWatch in a small child instead.
Trusting browser validation alone. Anything sent to your server must be checked again there.
React Hook Form re-renders less because inputs stay uncontrolled and it reads values through refs. Formik keeps every value in React state. For large forms, React Hook Form is usually faster and needs less code.
Install zod and @hookform/resolvers, define a z.object schema, and pass resolver: zodResolver(schema) to useForm. Use z.infer<typeof schema> as the form type, and error messages come from the schema.
Call reset() inside your submit handler after the save succeeds, or pass new values with reset(values). reset can also be called in an effect once isSubmitSuccessful turns true.
HTML inputs return text. Register with valueAsNumber: true, or use z.coerce.number() in a Zod schema, to get a real number.
Yes. shadcn/ui ships a Form component built on React Hook Form's Controller. Our shadcn form and table guide walks through it with a live example.
Related: Booking form generator, How to add a contact form, React Router examples, UI components, All free tools.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.