React Hook Form Examples, Tested and Live

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.

Install
NeedCommand
The librarynpm install react-hook-form
Schema validationnpm 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 cheat sheet

The parts of the API you will use most
APIUse 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 / isDirtyError messages and button states
mode: onSubmit | onBlur | onTouched | onChange | allWhen validation runs (onSubmit is the default)
resolver: zodResolver(schema)Validate with a Zod (or Yup, Valibot) schema
useFieldArrayRepeating rows: add, remove, move
Controller / useControllerCustom inputs without a ref
trigger(names)Validate some fields now, as in a wizard step
setError / clearErrorsErrors that come back from the server
reset(values)Load saved data or clear after submit
watch / useWatchRead a value as it changes (useWatch re-renders less)

Validation: built-in rules, Zod and async checks

Basic form with built-in rules

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>
  );
}

Zod schema with zodResolver

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>
  );
}

Async validation (is the username free?)

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>}

Field arrays, wizards, files and custom inputs

Field arrays (add and remove rows)

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>
  );
}

Multi-step wizard

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>
  );
}

File upload with size and type checks

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 });
};

Controlled components with Controller

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>}
    </>
  )}
/>

Submitting to the server

Next.js server action, validated twice

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>;
}

Accessible error messages

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.

Common mistakes

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.

How it works

  1. Call useForm, optionally with defaultValues and a resolver.
  2. Spread register("field") on each input; give every input a visible label.
  3. Wrap your submit function: onSubmit={handleSubmit(save)}.
  4. Show formState.errors.field?.message next to each field.
  5. Disable the button with isSubmitting while your save runs.

Tips

Frequently asked questions

Is React Hook Form better than Formik?

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.

How do I use React Hook Form with Zod?

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.

How do I reset a form after submit?

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.

Why is my number field a string?

HTML inputs return text. Register with valueAsNumber: true, or use z.coerce.number() in a Zod schema, to get a real number.

Can I use React Hook Form with shadcn/ui?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap