React Hook Form + Zod skill for Codex
One schema, typed fields, errors on the right input. Codex writes the Zod schema first in a module both client and server import, types useForm with separate input and output types so coerce and transform stop breaking the build, picks register or Controller per component, and maps server validation failures back onto the exact fields with focus on the first one. Empty-string and number-coercion traps are handled up front.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- react-hook-form-zod Installs to ~/.agents/skills/react-hook-form-zod/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install react-hook-form-zod The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project react-hook-form-zod Installs to .agents/skills/react-hook-form-zod/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/react-hook-form-zod.zipunzip -o react-hook-form-zod.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) react-hook-form-zod PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $react-hook-form-zod, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds React forms with React Hook Form v7 and Zod 4 through @hookform/resolvers - one schema shared by client and server, correct input versus output types, register versus Controller for UI-kit inputs, useFieldArray, server error mapping and accessible error markup. Use when creating or refactoring a React form, wiring zodResolver, fixing type errors from z.coerce or transforms, adding dynamic field lists, or showing server-side validation errors on fields.
Use it when
- Creating or refactoring a React form that needs client-side validation
- TypeScript complains about resolver or field types after schema changes
- A UI-kit select or date picker will not register with the form
- Server-side validation results need to appear next to the fields
Use something else when
- The form posts straight to a Next.js server action with no client state: use nextjs-server-actions
- The question is field layout, labels and error copy rather than code: use form-ux
How to check the result
tsc passes without casts, and a Testing Library test submits empty, invalid and valid input, asserting error text, aria-invalid and the exact payload sent.
The mistake it prevents
Treating an empty text input as undefined: HTML sends an empty string, so optional URL fields fail and coerced numbers silently become 0.
Done well means: one Zod schema is the single source of truth for both the browser and the server handler, the form’s TypeScript types come from that schema (input and output separately), every field error is announced to assistive technology, and the form re-renders only the fields that changed.
Before writing code
- Read
AGENTS.mdandpackage.json. Note the versions ofreact-hook-form,zodand@hookform/resolvers. Zod 4 needs@hookform/resolvers5.x; Zod 3 projects can stay on resolvers 3.x or 5.x. Installing packages needs network; ask before adding them. - Find existing form primitives (
components/ui/form.tsx, shadcn<Form>, a design systemTextField). Reuse them; do not invent a second field wrapper. - Find where the data is submitted: a fetch/route handler, a server action, tRPC. The
schema must live in a module both sides can import (for example
lib/schemas/), with no client-only or server-only imports.
Schema first
// lib/schemas/signup.ts
import { z } from "zod";
export const signupSchema = z
.object({
email: z.email({ error: "Enter a valid email address" }),
displayName: z.string().trim().min(2, { error: "At least 2 characters" }).max(40),
age: z.coerce.number().int().min(13, { error: "You must be 13 or older" }),
website: z.union([z.url(), z.literal("")]).optional(),
password: z.string().min(12),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
path: ["confirm"],
error: "Passwords do not match",
});
export type SignupInput = z.input<typeof signupSchema>; // what fields hold
export type SignupOutput = z.output<typeof signupSchema>; // what onSubmit receives
Zod 4 notes: top-level formats (z.email(), z.url(), z.uuid()) replace the method
forms, and { error: ... } replaces message (still accepted, deprecated). In Zod 3
use z.string().email() and { message }.
Wiring the form
"use client"; // only for Next.js App Router
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupInput, type SignupOutput } from "@/lib/schemas/signup";
export function SignupForm({ onCreated }: { onCreated: () => void }) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<SignupInput, unknown, SignupOutput>({
resolver: zodResolver(signupSchema),
mode: "onTouched",
defaultValues: { email: "", displayName: "", age: "", website: "", password: "", confirm: "" },
});
const onSubmit = async (data: SignupOutput) => {
const res = await fetch("/api/signup", { method: "POST", body: JSON.stringify(data) });
if (res.ok) return onCreated();
const body = await res.json();
applyServerErrors(body, setError);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<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>}
{/* ...other fields... */}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
<button type="submit" disabled={isSubmitting}>Create account</button>
</form>
);
}
The three generics are <TFieldValues, TContext, TTransformedValues>. Passing input and
output types explicitly removes the most common type error (“Type ‘unknown’ is not
assignable to type ‘number’”) whenever the schema uses coerce, transform, default
or pipe. Recent resolver versions infer this from zodResolver alone; explicit
generics still read better and survive refactors.
register or Controller
| Field | Use | Why |
|---|---|---|
Native input, select, textarea | register | Uncontrolled, no re-render per keystroke |
| Number input | register("age", { valueAsNumber: true }) or z.coerce.number() | DOM values are strings |
| Date input | register("start", { valueAsDate: true }) or z.coerce.date() | Same |
UI kit component exposing value/onChange only (MUI, Radix Select, react-select, date pickers) | <Controller> or useController | Needs controlled props |
Component that forwards ref to a real input | register | Works if name, onChange, onBlur, ref reach the input |
| Repeating group | useFieldArray | Stable keys and indexes |
<Controller
control={control}
name="country"
render={({ field, fieldState }) => (
<CountrySelect
value={field.value}
onValueChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
invalid={fieldState.invalid}
/>
)}
/>
Pass field.ref so setFocus and focus-on-error work.
Dynamic lists
const { fields, append, remove } = useFieldArray({ control, name: "contacts" });
{fields.map((f, i) => (
<div key={f.id}>
<input {...register(`contacts.${i}.email`)} />
{errors.contacts?.[i]?.email && <p role="alert">{errors.contacts[i].email.message}</p>}
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ email: "" })}>Add contact</button>
- Key by
f.id, never by index. - Array items must be objects. For a list of strings, wrap as
{ value: string }. appendneeds a complete default object, or validation runs onundefinedfields.- Array-level rules (
z.array(...).min(1)) report onerrors.contacts.root(orerrors.contacts.messageon older versions); render both places when unsure.
Server errors
Re-validate on the server with the same schema, then return field errors in a shape the client can map:
// server
const parsed = signupSchema.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ fieldErrors: z.flattenError(parsed.error).fieldErrors }, { status: 422 });
}
// business rule failures, e.g. email taken:
return Response.json({ fieldErrors: { email: ["Already registered"] } }, { status: 409 });
// client
function applyServerErrors(body: any, setError: UseFormSetError<SignupInput>) {
const fe = body?.fieldErrors ?? {};
let first = true;
for (const [name, msgs] of Object.entries(fe)) {
setError(name as keyof SignupInput, { type: "server", message: (msgs as string[])[0] }, { shouldFocus: first });
first = false;
}
if (!Object.keys(fe).length) setError("root.server", { message: "Something went wrong. Try again." });
}
z.flattenError is Zod 4; in Zod 3 use parsed.error.flatten(). root.* errors are not
cleared by revalidation; call clearErrors("root") at the start of each submit.
Loading existing data
- Known at render: pass
defaultValues. - Fetched async: pass
values: data(form syncs when data changes) or callreset(data)once in an effect after loading. Do not render the form with empty defaults and then patch withsetValuefield by field:isDirtywill be wrong. - Every field needs a default (
"", notundefined) or React warns about switching from uncontrolled to controlled.
Gotchas
- Empty text inputs submit
"", notundefined.z.string().optional()accepts"", andz.url().optional()rejects it. Use a union withz.literal("")or preprocess""toundefined. z.coerce.number()turns""into0, which passesmin(0). For required numbers, preprocess empty strings toundefinedfirst, or usevalueAsNumber(givesNaN, whichz.number()rejects).formStateis a proxy: destructure the properties you need during render (errors,isDirty) or they never update. ReadingformState.isValidcosts a validation run on every change; use it only if the UI needs it.watch()at the top of the component re-renders the whole form on every keystroke. UseuseWatch({ control, name })inside a small child component.- Refinements on
z.object(...).refinerun only after all object fields pass. For a cross-field check that should show while other fields are still invalid, usesuperRefineon the specific fields or trigger withtrigger(["password", "confirm"]). disabledinputs are excluded from submitted values. UsereadOnlyto show but submit.- Do not trust client validation for authorization or uniqueness; the server re-parses.
Verification
-
tsc --noEmitpasses with noas anyon form types. - A component test (React Testing Library +
user-event) submits empty, invalid and valid data and asserts the error text,aria-invalid, and the submitted payload. - A server test posts invalid JSON directly (bypassing the form) and gets 422 with field errors.
- Tab through the form with a keyboard; the first invalid field receives focus on submit.
- React DevTools “Highlight updates”: typing in one field does not re-render siblings.
Output
When delivering, summarise: schema file path, which fields use Controller and why,
how server errors map to fields, and any schema rule that differs between client and
server (there should be none unless documented).
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "React Hook Form + Zod"
short_description: "One schema, typed fields, errors on the right input"
default_prompt: "$react-hook-form-zod Build a signup form with React Hook Form and Zod, and show server errors on the fields" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.