This week, I saw three different developers struggle with the same issue: using react-hook-form as if it were just another useState wrapper. The library is one of the most efficient tools for handling forms in React SPAs — but only if we embrace its idiomatic patterns. Misusing it can quickly turn a simple form into a complex, brittle mess.

Let’s go through some of the most common mistakes I’ve seen, and how to fix them.

Mistake #1 — Ignoring register and Controller

Some developers avoid register or Controller entirely. Instead, they do something like this:

1const { watch, setValue } = useForm();
2
3<input
4  value={watch("email")}
5  onChange={(e) => setValue("email", e.target.value)}
6/>

This is essentially re-implementing controlled components with useState. It defeats the purpose of react-hook-form, which was designed to keep inputs uncontrolled and lightweight, reducing re-renders.

✅ The idiomatic way:

1const { register } = useForm();
2
3<input type="email" {...register("email")} />

Or, when working with complex components (like Select or DatePicker):

 1const { control } = useForm();
 2
 3<Controller
 4  name="date"
 5  control={control}
 6  render={({ field }) => (
 7    <DatePicker
 8      ref={field.ref}
 9      selected={field.value}
10      onChange={(date: Date, strDate: string) => field.onChange(strDate)}
11    />
12  )}
13/>

Mistake #2 — Manually passing form state down the tree

Another anti-pattern I’ve seen: using watch at the top level and passing props down manually, or even duplicating register calls in child components.

This makes forms verbose and error-prone. It also unnecessary more re-renders. Instead, use FormContext:

1const methods = useForm();
2
3<FormProvider {...methods}>
4  <FormChild />
5</FormProvider>

And in the child:

1const { register } = useFormContext();
2
3<input {...register("username")} />

This way, you keep form logic centralized, and every component can access the same form state without prop-drilling.

Mistake #3 — Overcomplicating default values

Many people struggle to preload form data. I’ve seen setups with multiple useEffect calls just to populate fields:

1useEffect(() => {
2  setValue("name", user.name);
3  setValue("email", user.email);
4}, [user]);

While this works, it’s noisy and mixes concerns. A more idiomatic approach is to keep the form logic focused only on form handling, not data fetching or loading state.

✅ Cleaner pattern: wrap the form in a parent component that handles async data:

1// UserFormWrapper.tsx
2export function UserFormWrapper() {
3  const { data: user, isLoading } = useUserQuery();
4
5  if (isLoading) return <div>Loading...</div>;
6  if (!user) return <div>No data found</div>;
7
8  return <UserForm initialData={user} />;
9}

Inside the form, you can directly use those props to initialize defaultValues:

 1// UserForm.tsx
 2export function UserForm({ initialData }) {
 3  const { register } = useForm({
 4    defaultValues: {
 5      name: initialData.name,
 6      email: initialData.email,
 7    },
 8  });
 9
10  return (
11    <form>
12      <input {...register("name")} />
13      <input {...register("email")} />
14    </form>
15  );
16}

This way:

  • The wrapper deals with async fetching, loading, and error states.
  • The form is always initialized with complete data, without juggling useEffect or reset.

It makes the component tree more declarative: “only render the form when I have data.”

Other common traps

  • Mixing uncontrolled and controlled inputs: either commit to register/Controller or you’ll risk inconsistent values.

  • Forgetting validation rules: RHF integrates naturally with schema validators like Zod. Don’t reinvent validation logic inside onSubmit.

  • Unnecessary re-renders: calling watch() at the top level on large forms without selectors will cause performance issues. Prefer useWatch for granular subscriptions.

Final thoughts

React Hook Form is a zero-boilerplate, zero-cost abstraction when used properly. But treating it like a glorified useState hook negates its benefits. If you lean into its idioms — register, Controller, FormProvider, defaultValues, and schema validation — your forms become simpler, faster, and more maintainable.