Use Cases (Updated: 6/2/2026)

React Hook Form with Claude Code: Safe Form Implementation Guide

Build React Hook Form screens with Claude Code, Zod validation, errors, submit states, tests, and safe prompts.

React Hook Form with Claude Code: Safe Form Implementation Guide

Decide the Form Contract Before Asking Claude Code

React Hook Form is a lightweight way to build forms in React. Instead of storing every keystroke in component state, it works with the browser’s native form behavior and gives you focused APIs such as register, handleSubmit, and formState. For beginners, that makes the important questions easier to answer: where are values collected, when does validation run, and how do we prevent a second submit while the first request is still pending?

Claude Code can draft the component, Zod schema, server route, tests, and refactor steps in one pass. That is useful, but forms often sit directly in the revenue path: contact requests, product trials, onboarding, payments, and newsletter signup. If the prompt is only “build a form”, you can easily get a nice-looking component that misses accessible error messages, server-side validation, or a reliable loading state.

This guide uses a contact inquiry form to show useForm, zodResolver, field errors, submit state, server-side revalidation, testability, and safer Claude Code prompts. For surrounding React patterns, see React development with Claude Code. For schema design, pair this with Zod validation with Claude Code.

The Architecture: Put the Schema in the Center

React Hook Form handles the form workflow. Zod describes which input values are valid. @hookform/resolvers/zod connects the two so React Hook Form can run the Zod schema during validation.

flowchart TD
  A["User types into fields"] --> B["React Hook Form register"]
  B --> C["zodResolver validates schema"]
  C --> D{"Valid input?"}
  D -->|No| E["Show field errors"]
  D -->|Yes| F["handleSubmit sends values"]
  F --> G["API validates the same schema"]
  G --> H["Store, notify, or sync to CRM"]

In plain language, useForm is the form controller, the Zod schema is the rule book, and the resolver is the adapter between them. When you ask Claude Code to modify a form, naming those three pieces reduces accidental rewrites. It also makes later changes safer because the prompt can say “update the schema, select options, server validation, and tests together.”

Use current official sources when checking details: the React Hook Form useForm documentation, React Hook Form Resolvers, the Zod API docs, React’s <input> reference, and the Claude Code overview and commands reference.

Copy-Paste Zod Schema

Start by putting validation in its own file. The example below covers a practical inquiry form with name, email, category, message, and consent. z.infer derives the TypeScript type from the schema, so you do not maintain a separate interface that can drift away from runtime validation.

// src/features/inquiry/inquirySchema.ts
import { z } from "zod";

export const inquirySchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, "Enter your name")
    .max(80, "Keep the name under 80 characters"),
  email: z
    .string()
    .trim()
    .email("Enter a valid email address"),
  category: z.enum(["consulting", "support", "billing"], {
    error: "Choose a category",
  }),
  message: z
    .string()
    .trim()
    .min(10, "Enter at least 10 characters")
    .max(1000, "Keep the message under 1000 characters"),
  agreeToContact: z.boolean().refine((value) => value, {
    message: "Consent is required",
  }),
});

export type InquiryFormValues = z.infer<typeof inquirySchema>;

The category field is an enum because the submitted value should be predictable. In real projects, those values often route a lead to sales, support, billing, or a CRM workflow. A good Claude Code prompt should specify both the visible label and the submitted value, for example “label: Billing, value: billing.” That keeps the UI readable while keeping downstream data stable.

Build the Form with useForm

The form component wires the schema into React Hook Form. mode: "onBlur" means validation runs after the user leaves a field. That is often calmer for beginner-facing contact forms than showing red errors while the user is still typing. handleSubmit still runs final validation before calling onSubmit.

// src/features/inquiry/InquiryForm.tsx
"use client";

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

async function sendInquiry(values: InquiryFormValues) {
  const response = await fetch("/api/inquiry", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(values),
  });

  if (!response.ok) {
    throw new Error("Failed to send inquiry");
  }
}

export function InquiryForm() {
  const {
    register,
    handleSubmit,
    reset,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<InquiryFormValues>({
    resolver: zodResolver(inquirySchema),
    mode: "onBlur",
    defaultValues: {
      name: "",
      email: "",
      message: "",
      agreeToContact: false,
    },
  });

  const onSubmit = async (values: InquiryFormValues) => {
    try {
      await sendInquiry(values);
      reset();
    } catch {
      setError("root", {
        type: "server",
        message: "We could not send the form. Please try again in a moment.",
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          autoComplete="name"
          aria-invalid={errors.name ? "true" : "false"}
          aria-describedby={errors.name ? "name-error" : undefined}
          {...register("name")}
        />
        {errors.name && (
          <p id="name-error" role="alert">
            {errors.name.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
          {...register("email")}
        />
        {errors.email && (
          <p id="email-error" role="alert">
            {errors.email.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="category">Topic</label>
        <select
          id="category"
          aria-invalid={errors.category ? "true" : "false"}
          aria-describedby={errors.category ? "category-error" : undefined}
          {...register("category")}
        >
          <option value="">Choose one</option>
          <option value="consulting">Implementation consulting</option>
          <option value="support">Technical support</option>
          <option value="billing">Billing or contract</option>
        </select>
        {errors.category && (
          <p id="category-error" role="alert">
            {errors.category.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          rows={6}
          aria-invalid={errors.message ? "true" : "false"}
          aria-describedby={errors.message ? "message-error" : undefined}
          {...register("message")}
        />
        {errors.message && (
          <p id="message-error" role="alert">
            {errors.message.message}
          </p>
        )}
      </div>

      <label>
        <input type="checkbox" {...register("agreeToContact")} />
        I agree to be contacted about this inquiry
      </label>
      {errors.agreeToContact && (
        <p role="alert">{errors.agreeToContact.message}</p>
      )}

      {errors.root && <p role="alert">{errors.root.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Sending..." : "Send inquiry"}
      </button>
    </form>
  );
}

Notice the accessible error wiring: each field uses aria-invalid, each field error has role="alert", and aria-describedby connects the input to its message. This is not decoration; it makes the form usable for screen reader users and easier to test. For more on that side, see Claude Code accessibility implementation.

Revalidate on the Server

Client-side validation improves UX, but it is not a security boundary. A user can bypass the browser form and call the API directly. Reuse the same schema on the server so the API rejects malformed data before storing it, sending email, or syncing to a CRM.

// app/api/inquiry/route.ts
import { NextResponse } from "next/server";
import { inquirySchema } from "@/features/inquiry/inquirySchema";

export async function POST(request: Request) {
  const payload = await request.json().catch(() => null);
  const parsed = inquirySchema.safeParse(payload);

  if (!parsed.success) {
    return NextResponse.json(
      {
        error: "Invalid inquiry",
        fields: parsed.error.flatten().fieldErrors,
      },
      { status: 400 },
    );
  }

  // TODO: Save to the database, send email, or sync to your CRM.
  return NextResponse.json({ ok: true });
}

When asking Claude Code to add the route, be explicit: “reuse inquirySchema, return 400 with field errors, and leave production email or CRM calls as TODOs.” That keeps the first implementation reviewable. Secret management, retries, and duplicate handling are separate tasks and should not be hidden inside a form refactor.

Make the Form Testable

Forms fail in ways that are easy to miss visually. At minimum, test empty submission, successful submission, server failure, and submit-button disabling. The example below uses Vitest and React Testing Library to prove that validation errors appear and a valid form calls fetch.

// src/features/inquiry/InquiryForm.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InquiryForm } from "./InquiryForm";

afterEach(() => {
  vi.unstubAllGlobals();
});

test("shows validation errors for an empty submit", async () => {
  render(<InquiryForm />);

  await userEvent.click(screen.getByRole("button", { name: "Send inquiry" }));

  expect(await screen.findAllByRole("alert")).toHaveLength(5);
});

test("submits valid values to the API", async () => {
  const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
  vi.stubGlobal("fetch", fetchMock);
  render(<InquiryForm />);

  await userEvent.type(screen.getByLabelText("Name"), "Masa");
  await userEvent.type(screen.getByLabelText("Email"), "masa@example.com");
  await userEvent.selectOptions(screen.getByLabelText("Topic"), "consulting");
  await userEvent.type(
    screen.getByLabelText("Message"),
    "I want help implementing React Hook Form safely.",
  );
  await userEvent.click(
    screen.getByLabelText("I agree to be contacted about this inquiry"),
  );
  await userEvent.click(screen.getByRole("button", { name: "Send inquiry" }));

  expect(fetchMock).toHaveBeenCalledWith(
    "/api/inquiry",
    expect.objectContaining({ method: "POST" }),
  );
});

You can ask Claude Code to write the failing tests first, then implement the form. For full-page flows, connect this to Playwright testing with Claude Code. For business tracking, connect successful submission to analytics implementation with Claude Code, but measure completion after the server accepts the form, not merely when the button is clicked.

Prompt Claude Code Safely

Good form prompts include scope, constraints, tests, and non-goals. Here is a reusable starter prompt:

Implement an inquiry form with React Hook Form and Zod.

Scope:
- Only edit src/features/inquiry and app/api/inquiry.
- Use useForm, zodResolver, and TypeScript types derived from the schema.
- Fields: name, email, category, message, agreeToContact.
- Show field errors with role="alert" and aria-describedby.
- Disable the submit button while isSubmitting is true.
- Revalidate the same Zod schema in the API route.
- Add Vitest + Testing Library tests.

Verification:
- npm test -- InquiryForm
- npm run typecheck

Do not:
- Add a new UI library.
- Rename existing category values.
- Implement production email, CRM, or secret handling in this task.

For a modification, be even more concrete. Instead of “add another category”, say “add label Training request with submitted value training; update the schema enum, select option, API validation, tests, and analytics mapping.” Claude Code can search related files, but the human should still define the contract.

Use Cases and Design Differences

Use caseGood structureWatch out for
Contact formZod + React Hook Form + API revalidationTrack completed leads, not button clicks
Profile editorLoad current data into defaultValuesCall reset(savedValues) after save to clear dirty state
Pre-purchase surveyCombine select, radio, and checkbox inputsKeep submitted values aligned with product or CRM IDs
Admin search formLighter validation and URL query syncAvoid firing an API request on every keystroke

The shared rule is simple: separate the UI label from the submitted value. Labels can be localized and rewritten. Submitted values should be stable, because reports, automations, and backend code depend on them. Give Claude Code a small table of labels and values before asking it to generate the component.

Common Pitfalls

The first pitfall is validating only in the browser. Client validation is helpful, but it is not enough. Import the shared schema into the API route and call safeParse before doing anything with the payload.

The second pitfall is losing the submit state immediately. If onSubmit does not await the async work, isSubmitting can return to false too early. Always return or await the fetch, database, or email promise, and send server failures to setError("root", ...).

The third pitfall is hiding errors far away from fields. A single banner that says “there are errors” is not enough. Put the message near the input, link it with aria-describedby, and use a summary only as an extra aid.

The fourth pitfall is letting Claude Code invent a new design system. If your app already has TextField, Select, Button, or toast components, mention them in the prompt. Adding a UI package during a form task increases review surface without solving the core problem.

The fifth pitfall is forgetting the post-submit path. A contact form needs a success message, thank-you page, email notification, analytics event, and sometimes CRM sync. Treat those as explicit tasks so a pretty form does not become a dead end.

Monetization CTA

Form quality should be judged by the funnel it supports. A simpler error message may raise valid completions more than a new animation. Before asking Claude Code to refactor, decide the business event: PDF signup, product lead, paid template purchase, or consultation request.

If you want a structured path, review the Claude Code materials on the products page or book implementation help through training and consulting. A form is a small component, but it is often the gate between useful content and revenue.

Tested Result Note

Masa tested this pattern on a small inquiry flow. The most useful change was centralizing the schema, because it prevented the common mistake of adding a select option in the UI but forgetting the API’s allowed values. The second useful change was the pair of tests for empty and valid submission. After asking Claude Code for follow-up edits, those tests caught missing error messages and a broken fetch call quickly. In practice, treating the form as an input contract is easier to maintain than treating it as only a UI component.

#Claude Code #React Hook Form #React #forms #validation
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.