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.
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 case | Good structure | Watch out for |
|---|---|---|
| Contact form | Zod + React Hook Form + API revalidation | Track completed leads, not button clicks |
| Profile editor | Load current data into defaultValues | Call reset(savedValues) after save to clear dirty state |
| Pre-purchase survey | Combine select, radio, and checkbox inputs | Keep submitted values aligned with product or CRM IDs |
| Admin search form | Lighter validation and URL query sync | Avoid 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.