← All posts
·15 min read

Claude Code with React Hook Form: Forms Without Re-Renders

Claude CodeReact Hook FormFormsValidation
Claude Code with React Hook Form: forms without re-renders

Why React Hook Form without CLAUDE.md produces controlled-input forms

React Hook Form is the most performant form library available to React developers in 2026. The library is small, the TypeScript types are accurate, and a basic form is six lines of JSX. The problem is that Claude Code does not understand React Hook Form's core philosophy: it is uncontrolled-by-default and routes state through refs, not React state. Without explicit constraints, Claude generates forms that work in the sense that submission fires, but produce code that re-renders on every keystroke, double-stores state, and conflicts with React Hook Form's internal subscription model.

The most common Claude defaults that hurt form quality: wrapping every input in Controller instead of using register, calling useState next to useForm to shadow the form state, using watch directly in render which causes re-render storms, omitting defaultValues and getting uncontrolled-to-controlled warnings, forgetting to wire a resolver for schema validation, and mismanaging field arrays with manual index tracking instead of useFieldArray. None of these surface as TypeScript errors because React Hook Form's API accepts the wrong patterns silently.

This guide covers the CLAUDE.md configuration that locks Claude Code into React Hook Form's correct model: register for native inputs, Controller only for custom components, schema resolvers for validation, useWatch for reactive UI without render thrash, and the form composition patterns that scale to dozens of fields. If you are building a Next.js application with Server Actions, Claude Code with Next.js covers the form submission patterns that pair with React Hook Form. For schema validation that integrates cleanly, Claude Code with Zod shows the resolver setup that makes types flow end to end.

The React Hook Form CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a React Hook Form integration it needs to declare: the package version, the register vs Controller decision rules, the resolver pattern, the defaultValues policy, the field array convention, and the hard rules that block the mistakes Claude makes most often.

# React Hook Form rules

## Stack
- react-hook-form ^7.x, React 18.x or 19.x, TypeScript 5.x strict
- @hookform/resolvers ^3.x for schema validation
- zod ^3.x as the default schema library (swap for valibot/yup as needed)

## Project structure
- src/forms/                  , one folder per form
- src/forms/{name}/schema.ts  , Zod schema, exports type + schema
- src/forms/{name}/form.tsx   , the form component
- src/forms/{name}/index.ts   , barrel export

## Register vs Controller
- USE register: native inputs (input, textarea, select)
- USE Controller: custom components that accept value+onChange but not ref
- NEVER wrap a native input in Controller (wastes a layer)
- NEVER use register on a custom component that expects React props (breaks ref)

## Default values (MANDATORY)
- Every useForm() call MUST pass defaultValues
- defaultValues type MUST match the schema's inferred type exactly
- For optional fields, use empty string "" or undefined consistently
- NEVER let RHF infer defaults from input value attributes

## Reactive UI
- For UI that depends on form values, use useWatch (subscription, no re-render of form)
- NEVER call watch() in component render
- For one-off reads, use getValues()
- For form-state-driven UI, use formState destructured at the top

## Validation
- ALWAYS use a resolver (zodResolver / valibotResolver / yupResolver)
- NEVER use the rules prop on register for production forms
- Schema defines the contract, resolver applies it, types flow from inference

## Hard rules
- NEVER call useState for fields that React Hook Form manages
- NEVER call watch() directly in render (use useWatch with subscription)
- NEVER omit defaultValues
- NEVER mix register and Controller for the same field
- ALWAYS handle isSubmitting state on the submit button
- ALWAYS pass mode: "onBlur" or "onChange" explicitly (don't rely on default "onSubmit")

Three rules here prevent the majority of production issues Claude generates without them.

The register vs Controller rule is the most impactful for performance. register connects directly to the DOM via refs and bypasses React state entirely. Controller adds a controlled-component wrapper with its own subscription. Using Controller on a native <input> doubles the work React Hook Form has to do per keystroke. Claude defaults to Controller because the examples in many tutorials show it for clarity, even when register is the correct choice.

The defaultValues rule prevents a chain of subtle bugs. Without defaultValues, React Hook Form sees inputs change from undefined to a value on first keystroke and React logs the uncontrolled-to-controlled warning. reset() becomes unpredictable. Type inference for watch() and getValues() gives back optional types where they should be concrete. The fix is one line per form: pass complete defaults.

The watch rule prevents render storms. watch() returns the current value but subscribes the entire component tree to every field change. A single-field watch in a 30-field form re-renders the whole form on every keystroke. useWatch({ control, name }) subscribes only the consuming component. The rule banning watch() in render and requiring useWatch for reactive UI removes an entire class of performance bugs.

Install and provider setup

Install React Hook Form and the resolver package:

npm i react-hook-form @hookform/resolvers zod

No global provider is needed. Each form calls useForm() independently. Create the recommended directory structure:

src/forms/
  signup/
    schema.ts
    form.tsx
    index.ts
  contact/
    schema.ts
    form.tsx
    index.ts

Schema-first form pattern

The recommended flow is schema first, then form. Define the contract in Zod, infer the TypeScript type, and bind both via the resolver.

// src/forms/signup/schema.ts
import { z } from "zod";

export const signupSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
  confirmPassword: z.string(),
  name: z.string().min(1, "Name is required"),
  acceptedTerms: z.literal(true, {
    errorMap: () => ({ message: "You must accept the terms" }),
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"],
});

export type SignupFormValues = z.infer<typeof signupSchema>;

The form component:

// src/forms/signup/form.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupFormValues } from "./schema";

interface SignupFormProps {
  onSubmit: (values: SignupFormValues) => Promise<void>;
}

export function SignupForm({ onSubmit }: SignupFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
  } = useForm<SignupFormValues>({
    resolver: zodResolver(signupSchema),
    mode: "onBlur",
    defaultValues: {
      email: "",
      password: "",
      confirmPassword: "",
      name: "",
      acceptedTerms: false as unknown as true,
    },
  });

  const handleFormSubmit = async (values: SignupFormValues) => {
    await onSubmit(values);
    reset();
  };

  return (
    <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          {...register("email")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          type="text"
          autoComplete="name"
          {...register("name")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {errors.name && (
          <p className="mt-1 text-sm text-red-600">{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          id="password"
          type="password"
          autoComplete="new-password"
          {...register("password")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {errors.password && (
          <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="confirmPassword" className="block text-sm font-medium">
          Confirm password
        </label>
        <input
          id="confirmPassword"
          type="password"
          autoComplete="new-password"
          {...register("confirmPassword")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {errors.confirmPassword && (
          <p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
        )}
      </div>

      <label className="flex items-start gap-2">
        <input type="checkbox" {...register("acceptedTerms")} className="mt-1" />
        <span className="text-sm">I accept the terms of service</span>
      </label>
      {errors.acceptedTerms && (
        <p className="text-sm text-red-600">{errors.acceptedTerms.message}</p>
      )}

      <button
        type="submit"
        disabled={isSubmitting}
        className="w-full rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        {isSubmitting ? "Creating account..." : "Sign up"}
      </button>
    </form>
  );
}

Five things make this form correct:

  1. zodResolver wires the schema into validation. Errors come back typed and keyed to the schema fields.
  2. mode: "onBlur" validates on blur instead of waiting for submit. Submit-only validation produces a poor experience where every field appears wrong at once.
  3. defaultValues is complete and matches the schema type. No uncontrolled-to-controlled warning, no surprises with reset().
  4. register("fieldName") is spread on each input. No Controller wrapper because these are native inputs.
  5. isSubmitting disables the submit button and shows a pending state.

Controller for custom components

Controller is required for components that do not accept a ref to the underlying DOM element. Date pickers, select libraries, rich text editors, and most headless UI components fall into this category.

"use client";

import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import Select from "react-select";

const schema = z.object({
  country: z.object({
    value: z.string(),
    label: z.string(),
  }, { required_error: "Select a country" }),
  dateOfBirth: z.date({ required_error: "Date of birth required" }),
});

type FormValues = z.infer<typeof schema>;

const countries = [
  { value: "us", label: "United States" },
  { value: "uk", label: "United Kingdom" },
  { value: "ae", label: "United Arab Emirates" },
];

export function ProfileForm({ onSubmit }: { onSubmit: (values: FormValues) => void }) {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    mode: "onBlur",
    defaultValues: {
      country: undefined,
      dateOfBirth: undefined,
    },
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Country</label>
        <Controller
          control={control}
          name="country"
          render={({ field }) => (
            <Select
              {...field}
              options={countries}
              instanceId="country-select"
              className="mt-1"
            />
          )}
        />
        {errors.country && (
          <p className="mt-1 text-sm text-red-600">{errors.country.message}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">Date of birth</label>
        <Controller
          control={control}
          name="dateOfBirth"
          render={({ field: { onChange, value } }) => (
            <input
              type="date"
              value={value ? value.toISOString().slice(0, 10) : ""}
              onChange={(e) => onChange(e.target.value ? new Date(e.target.value) : undefined)}
              className="mt-1 block w-full rounded-md border px-3 py-2"
            />
          )}
        />
        {errors.dateOfBirth && (
          <p className="mt-1 text-sm text-red-600">{errors.dateOfBirth.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        Submit
      </button>
    </form>
  );
}

Controller provides the field object (onChange, value, name, ref, onBlur) that custom components need to integrate. The render prop is where the custom component receives these.

Add the register vs Controller decision tree to CLAUDE.md:

## Register vs Controller decision tree

Native inputs:
- <input type="text|email|password|date|number|search|tel|url"> => register
- <textarea> => register
- <select><option>...</option></select> => register
- <input type="checkbox|radio"> => register

Custom or library components:
- React Select, React Datepicker, Mantine, Chakra Field => Controller
- Headless UI Listbox, Combobox => Controller
- Rich text editors (TipTap, Slate, Lexical) => Controller
- Any component without a forwarded ref to the DOM input => Controller

If unsure: try register first, if it fails with a ref warning, switch to Controller

Reactive UI with useWatch

Forms often need UI that depends on field values: showing a confirmation field only when a checkbox is checked, computing a total from line items, displaying a password strength indicator. The wrong way to do this is const value = watch("field") in render. The right way is useWatch({ control, name }) from a child component.

"use client";

import { useForm, useWatch, Control } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  password: z.string().min(8),
  showPassword: z.boolean(),
});

type FormValues = z.infer<typeof schema>;

function PasswordStrengthIndicator({ control }: { control: Control<FormValues> }) {
  const password = useWatch({ control, name: "password" });

  const strength = (() => {
    if (!password) return 0;
    let score = 0;
    if (password.length >= 8) score++;
    if (/[A-Z]/.test(password)) score++;
    if (/[0-9]/.test(password)) score++;
    if (/[^A-Za-z0-9]/.test(password)) score++;
    return score;
  })();

  const labels = ["Too weak", "Weak", "Fair", "Good", "Strong"];

  return (
    <div className="mt-2">
      <div className="h-2 w-full rounded bg-gray-200">
        <div
          className="h-full rounded bg-green-500 transition-all"
          style={{ width: `${(strength / 4) * 100}%` }}
        />
      </div>
      <p className="mt-1 text-sm text-gray-600">{labels[strength]}</p>
    </div>
  );
}

export function PasswordForm() {
  const { register, control, handleSubmit } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: { password: "", showPassword: false },
  });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">Password</label>
        <input
          type="password"
          {...register("password")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        <PasswordStrengthIndicator control={control} />
      </div>
      <button type="submit" className="rounded-md bg-black px-4 py-2 text-white">
        Submit
      </button>
    </form>
  );
}

PasswordStrengthIndicator is the only component that re-renders on password keystrokes. The parent PasswordForm does not re-render. The input itself does not re-render (it is uncontrolled via register). This is the React Hook Form performance promise in action.

For a deeper dive into the React patterns that make this isolation possible, Claude Code with React covers the subscription and memoisation rules that pair with form libraries.

Field arrays

Dynamic lists of fields (line items, recipients, addresses) need useFieldArray. Without it, Claude generates manual array manipulation that breaks register keys on insertion and deletion.

"use client";

import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  invoiceTitle: z.string().min(1),
  items: z
    .array(
      z.object({
        description: z.string().min(1),
        quantity: z.coerce.number().min(1),
        price: z.coerce.number().min(0),
      }),
    )
    .min(1, "Add at least one line item"),
});

type InvoiceFormValues = z.infer<typeof schema>;

export function InvoiceForm({ onSubmit }: { onSubmit: (values: InvoiceFormValues) => void }) {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<InvoiceFormValues>({
    resolver: zodResolver(schema),
    mode: "onBlur",
    defaultValues: {
      invoiceTitle: "",
      items: [{ description: "", quantity: 1, price: 0 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "items",
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      <div>
        <label className="block text-sm font-medium">Invoice title</label>
        <input
          {...register("invoiceTitle")}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {errors.invoiceTitle && (
          <p className="mt-1 text-sm text-red-600">{errors.invoiceTitle.message}</p>
        )}
      </div>

      <div className="space-y-3">
        {fields.map((field, index) => (
          <div key={field.id} className="grid grid-cols-12 gap-2">
            <input
              {...register(`items.${index}.description`)}
              placeholder="Description"
              className="col-span-6 rounded-md border px-3 py-2"
            />
            <input
              type="number"
              {...register(`items.${index}.quantity`)}
              placeholder="Qty"
              className="col-span-2 rounded-md border px-3 py-2"
            />
            <input
              type="number"
              step="0.01"
              {...register(`items.${index}.price`)}
              placeholder="Price"
              className="col-span-3 rounded-md border px-3 py-2"
            />
            <button
              type="button"
              onClick={() => remove(index)}
              className="col-span-1 rounded-md border px-2 text-red-600"
              aria-label={`Remove item ${index + 1}`}
            >
              ×
            </button>
          </div>
        ))}
      </div>

      <button
        type="button"
        onClick={() => append({ description: "", quantity: 1, price: 0 })}
        className="rounded-md border px-4 py-2"
      >
        Add line item
      </button>

      <button
        type="submit"
        disabled={isSubmitting}
        className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
      >
        Save invoice
      </button>
    </form>
  );
}

The critical detail is key={field.id} on each row. React Hook Form generates a stable id for each field array entry that survives reorders. Using the index as the key causes React to reuse inputs across positions, which corrupts state when items are inserted or removed mid-list.

Add the field array pattern to CLAUDE.md:

## Field arrays

- useFieldArray for dynamic lists, NEVER manual array manipulation
- key={field.id} on the mapped row, NEVER key={index}
- register paths use template literals: register(`items.${index}.field`)
- Methods: append, prepend, insert, remove, swap, move, replace
- Validation: schema applies the array constraint (min length, max length, item shape)
- defaultValues MUST include at least one item for the array

Async validation

Real forms need server-side validation: username uniqueness, email already taken, promo code valid. Two patterns work.

The first is the validate function on register or in the schema. The second is setError after submit.

"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const schema = z.object({
  username: z.string().min(3),
  email: z.string().email(),
});

type FormValues = z.infer<typeof schema>;

async function checkUsernameAvailable(username: string): Promise<boolean> {
  const res = await fetch(`/api/check-username?u=${encodeURIComponent(username)}`);
  const { available } = await res.json();
  return available;
}

export function SignupForm() {
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    mode: "onBlur",
    defaultValues: { username: "", email: "" },
  });

  const onSubmit = async (values: FormValues) => {
    const available = await checkUsernameAvailable(values.username);
    if (!available) {
      setError("username", {
        type: "server",
        message: "Username is already taken",
      });
      return;
    }

    const res = await fetch("/api/signup", {
      method: "POST",
      body: JSON.stringify(values),
    });

    if (!res.ok) {
      const { errors: serverErrors } = await res.json();
      for (const [field, message] of Object.entries(serverErrors)) {
        setError(field as keyof FormValues, {
          type: "server",
          message: message as string,
        });
      }
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <input {...register("username")} placeholder="Username" />
      {errors.username && <p>{errors.username.message}</p>}
      <input {...register("email")} type="email" placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Checking..." : "Sign up"}
      </button>
    </form>
  );
}

setError is the canonical pattern for surfacing server-side validation errors back to specific fields. The error keys must match the schema field paths exactly.

Add async validation to CLAUDE.md:

## Async validation

- Pre-submit checks: validate function on register, returns true/error message
- Post-submit server errors: setError("fieldName", { type: "server", message })
- For debounced async checks (username, email): debounce in the validate function,
  return a promise that resolves to true/string
- NEVER block the UI thread waiting for async validation, always show pending state

Looking to ship type-safe forms faster? Get Claudify. Pre-built CLAUDE.md templates for React Hook Form and every major React tool, ready to drop into your project.

Common Claude Code mistakes with React Hook Form

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Controller on a native input

Claude generates: <Controller control={control} name="email" render={({ field }) => <input {...field} />} />.

Correct pattern: <input {...register("email")} />.

2. useState shadowing form state

Claude generates: const [email, setEmail] = useState("") next to useForm(), then sets both on change.

Correct pattern: useForm is the single source of truth, never call useState for form fields.

3. watch() in render

Claude generates: const password = watch("password") at the top of the form component, then renders UI from it.

Correct pattern: extract the consuming UI to a child component that calls useWatch({ control, name: "password" }).

4. Missing defaultValues

Claude generates: useForm<FormValues>({ resolver }) with no defaults.

Correct pattern: useForm<FormValues>({ resolver, defaultValues: { email: "", name: "", ... } }).

5. Index as field array key

Claude generates: {fields.map((field, index) => <div key={index}>...).

Correct pattern: {fields.map((field, index) => <div key={field.id}>...).

6. No isSubmitting state on button

Claude generates: <button type="submit">Submit</button> with no disabled or pending state.

Correct pattern: <button type="submit" disabled={isSubmitting}>{isSubmitting ? "Saving..." : "Submit"}</button>.

Mistake Symptom Fix
Controller on native input Slow keystrokes register
useState shadow Form state divergence useForm only
watch() in render Re-render storm useWatch in child
No defaultValues Console warnings, reset bugs Complete defaults
index as key Corrupted state on remove field.id
No submit pending Double submissions disabled+isSubmitting

Add a common mistakes section to CLAUDE.md with these six pairs. Concrete before/after pairs are more reliable than abstract rules for Claude to internalise.

Building forms that ship

The React Hook Form CLAUDE.md in this guide produces form code where register is used for native inputs, Controller only for custom components, schemas define the validation contract, useWatch powers reactive UI without render storms, useFieldArray handles dynamic lists with stable keys, and setError surfaces server-side validation back to the form.

The underlying principle is the same as any library integration with Claude Code. React Hook Form without a CLAUDE.md produces code that compiles cleanly but fails in ways that are hard to diagnose: forms that re-render on every keystroke, field arrays that corrupt state when items are removed, async validation that races with submission, and resolvers wired with the wrong schema type. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.

For the next layer up from forms, React Hook Form pairs well with Claude Code with Zod for schema-driven validation and Claude Code with Next.js for server actions that consume validated form data. Claudify includes a React Hook Form CLAUDE.md template with the register vs Controller decision tree, resolver patterns, field array conventions, async validation flows, and all six common-mistake pairs pre-configured.

Get Claudify. Ship production-ready forms with Claude Code from the first session.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame