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.

  • Frontend
  • $react-hook-form-zod
  • 229 lines
  • 2 files
  • ~2,350 tokens
  • MIT
  • Updated

Install

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.

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.

~/.agents/skills/react-hook-form-zod/SKILL.md

--- name: react-hook-form-zod description: Builds React forms with React Hook Form v7 and Zod 4 through @hookform/resolvers - one schema shared by client and server, correct input versus… license: MIT ---

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

  1. Read AGENTS.md and package.json. Note the versions of react-hook-form, zod and @hookform/resolvers. Zod 4 needs @hookform/resolvers 5.x; Zod 3 projects can stay on resolvers 3.x or 5.x. Installing packages needs network; ask before adding them.
  2. Find existing form primitives (components/ui/form.tsx, shadcn <Form>, a design system TextField). Reuse them; do not invent a second field wrapper.
  3. 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

FieldUseWhy
Native input, select, textarearegisterUncontrolled, no re-render per keystroke
Number inputregister("age", { valueAsNumber: true }) or z.coerce.number()DOM values are strings
Date inputregister("start", { valueAsDate: true }) or z.coerce.date()Same
UI kit component exposing value/onChange only (MUI, Radix Select, react-select, date pickers)<Controller> or useControllerNeeds controlled props
Component that forwards ref to a real inputregisterWorks if name, onChange, onBlur, ref reach the input
Repeating groupuseFieldArrayStable 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 }.
  • append needs a complete default object, or validation runs on undefined fields.
  • Array-level rules (z.array(...).min(1)) report on errors.contacts.root (or errors.contacts.message on 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 call reset(data) once in an effect after loading. Do not render the form with empty defaults and then patch with setValue field by field: isDirty will be wrong.
  • Every field needs a default ("", not undefined) or React warns about switching from uncontrolled to controlled.

Gotchas

  • Empty text inputs submit "", not undefined. z.string().optional() accepts "", and z.url().optional() rejects it. Use a union with z.literal("") or preprocess "" to undefined.
  • z.coerce.number() turns "" into 0, which passes min(0). For required numbers, preprocess empty strings to undefined first, or use valueAsNumber (gives NaN, which z.number() rejects).
  • formState is a proxy: destructure the properties you need during render (errors, isDirty) or they never update. Reading formState.isValid costs 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. Use useWatch({ control, name }) inside a small child component.
  • Refinements on z.object(...).refine run only after all object fields pass. For a cross-field check that should show while other fields are still invalid, use superRefine on the specific fields or trigger with trigger(["password", "confirm"]).
  • disabled inputs are excluded from submitted values. Use readOnly to show but submit.
  • Do not trust client validation for authorization or uniqueness; the server re-parses.

Verification

  • tsc --noEmit passes with no as any on 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.yamlgenerated from this page

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.

esc