PluginBench
Skill
Pass
Audit score 90

react-hook-form-zod

jezweb/claude-skills

Type-safe validated forms with React Hook Form v7 and Zod v4, with full TypeScript inference and server validation.

What is react-hook-form-zod?

Build client and server forms with a single Zod schema that provides complete type safety and validation. Use this when creating forms, multi-step wizards, or resolving uncontrolled component warnings and resolver errors.

  • Connect Zod schemas to React Hook Form with zodResolver for automatic validation
  • Use z.infer<typeof schema> for full TypeScript type inference on form data
  • Support dynamic fields with useFieldArray, including nested validation and error handling
  • Handle cross-field validation with Zod refinements and conditional validation via discriminatedUnion
  • Integrate with third-party components using Controller while maintaining type safety
  • Optimize performance for large forms (300+ fields) by avoiding formState destructuring and using mode: 'onSubmit'

How to install react-hook-form-zod

npx skills add https://github.com/jezweb/claude-skills --skill react-hook-form-zod
Prerequisites
  • React 16.8+ (for hooks)
  • npm or yarn package manager
  • TypeScript (recommended for full type inference benefits)
Claude Code
Cursor
Windsurf
Cline

How to use react-hook-form-zod

  1. 1.Install dependencies: npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2
  2. 2.Define a Zod schema with z.object() and validation rules (e.g., z.string().email())
  3. 3.Create a TypeScript type using z.infer<typeof schema> for your form data
  4. 4.Call useForm with zodResolver(schema) and set defaultValues to prevent uncontrolled warnings
  5. 5.Use register() for standard HTML inputs or Controller for third-party components
  6. 6.Display validation errors from formState.errors with optional chaining for nested fields
  7. 7.Call the same schema.parse() on your server to validate submitted data before processing

Use cases

Good for
  • Building multi-step form wizards with step-by-step validation and shouldUnregister for cleanup
  • Creating dynamic contact lists or line-item forms with useFieldArray and nested error messages
  • Implementing conditional form fields that show/hide based on user selections with discriminatedUnion schemas
  • Fixing uncontrolled component warnings by setting defaultValues and using register for standard inputs
  • Validating forms on both client (React Hook Form) and server (same Zod schema) to prevent bypasses
Who it's for
  • React developers building production forms with TypeScript
  • Teams requiring client and server validation with a single source of truth
  • Developers working with shadcn/ui or custom component libraries
  • Engineers optimizing form performance in applications with many fields

react-hook-form-zod FAQ

Why do I get uncontrolled component warnings?

You must set defaultValues in useForm options. Even empty strings like { email: '', password: '' } prevent React from switching between uncontrolled and controlled modes.

Should I use register or Controller?

Use register for standard HTML inputs (best performance, uncontrolled). Use Controller only for third-party components like React Select or date pickers that don't support refs.

How do I validate across multiple fields?

Use Zod refinements with z.object().refine((data) => ..., { message: '...', path: ['fieldName'] }) to set the error on a specific field, or use discriminatedUnion for conditional schemas.

Why does my large form (300+ fields) freeze during registration?

Reading formState properties (isDirty, isValid) while using a resolver causes performance issues. Use mode: 'onSubmit', read formState inline only when needed, or split into multiple smaller forms.

Do I need to validate on the server if I validate on the client?

Yes. Always validate on the server using the same Zod schema via schema.parse(). Client validation can be bypassed and is only for UX; server validation is a security requirement.

Full instructions (SKILL.md)

Source of truth, from jezweb/claude-skills.


name: react-hook-form-zod description: | Build type-safe validated forms using React Hook Form v7 and Zod v4. Single schema works on client and server with full TypeScript inference via z.infer.

Use when building forms, multi-step wizards, or fixing uncontrolled warnings, resolver errors, useFieldArray issues, performance problems with large forms. user-invocable: true

React Hook Form + Zod Validation

Status: Production Ready ✅ Last Verified: 2026-01-20 Latest Versions: react-hook-form@7.71.1, zod@4.3.5, @hookform/resolvers@5.2.2


Quick Start

npm install react-hook-form@7.70.0 zod@4.3.5 @hookform/resolvers@5.2.2

Basic Form Pattern:

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

type FormData = z.infer<typeof schema>

const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
  resolver: zodResolver(schema),
  defaultValues: { email: '', password: '' }, // REQUIRED to prevent uncontrolled warnings
})

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register('email')} />
  {errors.email && <span role="alert">{errors.email.message}</span>}
</form>

Server Validation (CRITICAL - never skip):

// SAME schema on server
const data = schema.parse(await req.json())

Key Patterns

useForm Options (validation modes):

  • mode: 'onSubmit' (default) - Best performance
  • mode: 'onBlur' - Good balance
  • mode: 'onChange' - Live feedback, more re-renders
  • shouldUnregister: true - Remove field data when unmounted (use for multi-step forms)

Zod Refinements (cross-field validation):

z.object({ password: z.string(), confirm: z.string() })
  .refine((data) => data.password === data.confirm, {
    message: "Passwords don't match",
    path: ['confirm'], // CRITICAL: Error appears on this field
  })

Zod Transforms:

z.string().transform((val) => val.toLowerCase()) // Data manipulation
z.string().transform(parseInt).refine((v) => v > 0) // Chain with refine

Zod v4.3.0+ Features:

// Exact optional (can omit field, but NOT undefined)
z.string().exactOptional()

// Exclusive union (exactly one must match)
z.xor([z.string(), z.number()])

// Import from JSON Schema
z.fromJSONSchema({ type: "object", properties: { name: { type: "string" } } })

zodResolver connects Zod to React Hook Form, preserving type safety


Registration

register (for standard HTML inputs):

<input {...register('email')} /> // Uncontrolled, best performance

Controller (for third-party components):

<Controller
  name="category"
  control={control}
  render={({ field }) => <CustomSelect {...field} />} // MUST spread {...field}
/>

When to use Controller: React Select, date pickers, custom components without ref. Otherwise use register.


Error Handling

Display errors:

{errors.email && <span role="alert">{errors.email.message}</span>}
{errors.address?.street?.message} // Nested errors (use optional chaining)

Server errors:

const onSubmit = async (data) => {
  const res = await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) })
  if (!res.ok) {
    const { errors: serverErrors } = await res.json()
    Object.entries(serverErrors).forEach(([field, msg]) => setError(field, { message: msg }))
  }
}

Advanced Patterns

useFieldArray (dynamic lists):

const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })

{fields.map((field, index) => (
  <div key={field.id}> {/* CRITICAL: Use field.id, NOT index */}
    <input {...register(`contacts.${index}.name` as const)} />
    {errors.contacts?.[index]?.name && <span>{errors.contacts[index].name.message}</span>}
    <button onClick={() => remove(index)}>Remove</button>
  </div>
))}
<button onClick={() => append({ name: '', email: '' })}>Add</button>

Async Validation (debounce):

const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)

Multi-Step Forms:

const step1 = z.object({ name: z.string(), email: z.string().email() })
const step2 = z.object({ address: z.string() })
const fullSchema = step1.merge(step2)

const nextStep = async () => {
  const isValid = await trigger(['name', 'email']) // Validate specific fields
  if (isValid) setStep(2)
}

Conditional Validation:

z.discriminatedUnion('accountType', [
  z.object({ accountType: z.literal('personal'), name: z.string() }),
  z.object({ accountType: z.literal('business'), companyName: z.string() }),
])

Conditional Fields with shouldUnregister:

const form = useForm({
  resolver: zodResolver(schema),
  shouldUnregister: false, // Keep values when fields unmount (default)
})

// Or use conditional schema validation:
z.object({
  showAddress: z.boolean(),
  address: z.string(),
}).refine((data) => {
  if (data.showAddress) {
    return data.address.length > 0;
  }
  return true;
}, {
  message: "Address is required",
  path: ["address"],
})

shadcn/ui Integration

Note: shadcn/ui deprecated the Form component. Use the Field component for new implementations (check latest docs).

Common Import Mistake: IDEs/AI may auto-import Form from "react-hook-form" instead of from shadcn. Always import:

// ✅ Correct:
import { useForm } from "react-hook-form";
import { Form, FormField, FormItem } from "@/components/ui/form"; // shadcn

// ❌ Wrong (auto-import mistake):
import { useForm, Form } from "react-hook-form";

Legacy Form component:

<FormField control={form.control} name="username" render={({ field }) => (
  <FormItem>
    <FormControl><Input {...field} /></FormControl>
    <FormMessage />
  </FormItem>
)} />

Performance

  • Use register (uncontrolled) over Controller (controlled) for standard inputs
  • Use watch('email') not watch() (isolates re-renders to specific fields)
  • shouldUnregister: true for multi-step forms (clears data on unmount)

Large Forms (300+ Fields)

Warning: Forms with 300+ fields using a resolver (Zod/Yup) AND reading formState properties can freeze for 10-15 seconds during registration. (Issue #13129)

Performance Characteristics:

  • Clean (no resolver, no formState read): Almost immediate
  • With resolver only: Almost immediate
  • With formState read only: Almost immediate
  • With BOTH resolver + formState read: ~9.5 seconds for 300 fields

Workarounds:

  1. Avoid destructuring formState - Read properties inline only when needed:
// ❌ Slow with 300+ fields:
const { isDirty, isValid } = form.formState;

// ✅ Fast:
const handleSubmit = () => {
  if (!form.formState.isValid) return; // Read inline only when needed
};
  1. Use mode: "onSubmit" - Don't validate on every change:
const form = useForm({
  resolver: zodResolver(largeSchema),
  mode: "onSubmit", // Validate only on submit, not onChange
});
  1. Split into sub-forms - Multiple smaller forms with separate schemas:
// Instead of one 300-field form, use 5-6 forms with 50-60 fields each
const form1 = useForm({ resolver: zodResolver(schema1) }); // Fields 1-50
const form2 = useForm({ resolver: zodResolver(schema2) }); // Fields 51-100
  1. Lazy render fields - Use tabs/accordion to mount only visible fields:
// Only mount fields for active tab, reduces initial registration time
{activeTab === 'personal' && <PersonalInfoFields />}
{activeTab === 'address' && <AddressFields />}

Critical Rules

✅ Always set defaultValues (prevents uncontrolled→controlled warnings)

✅ Validate on BOTH client and server (client can be bypassed - security!)

✅ Use field.id as key in useFieldArray (not index)

✅ Spread {...field} in Controller render

✅ Use z.infer<typeof schema> for type inference

❌ Never skip server validation (security vulnerability)

❌ Never mutate values directly (use setValue())

❌ Never mix controlled + uncontrolled patterns

❌ Never use index as key in useFieldArray


Known Issues (20 Prevented)

  1. Zod v4 Type Inference - #13109: Use z.infer<typeof schema> explicitly. Resolved in v7.66.x+. Note: @hookform/resolvers has TypeScript compatibility issues with Zod v4 (#813). Workaround: Use import { z } from 'zod/v3' or wait for resolver update.

  2. Uncontrolled→Controlled Warning - Always set defaultValues for all fields

  3. Nested Object Errors - Use optional chaining: errors.address?.street?.message

  4. Array Field Re-renders - Use key={field.id} in useFieldArray (not index)

  5. Async Validation Race Conditions - Debounce validation, cancel pending requests

  6. Server Error Mapping - Use setError() to map server errors to fields

  7. Default Values Not Applied - Set defaultValues in useForm options (not useState)

  8. Controller Field Not Updating - Always spread {...field} in render function

  9. useFieldArray Key Warnings - Use field.id as key (not index)

  10. Schema Refinement Error Paths - Specify path in refinement: refine(..., { path: ['fieldName'] })

  11. Transform vs Preprocess - Use transform for output, preprocess for input

  12. Multiple Resolver Conflicts - Use single resolver (zodResolver), combine schemas if needed

  13. Zod v4 Optional Fields Bug - #13102: Setting optional fields (.optional()) to empty string "" incorrectly triggers validation errors. Workarounds: Use .nullish(), .or(z.literal("")), or z.preprocess((val) => val === "" ? undefined : val, z.email().optional())

  14. useFieldArray Primitive Arrays Not Supported - #12570: Design limitation. useFieldArray only works with arrays of objects, not primitives like string[]. Workaround: Wrap primitives in objects: [{ value: "string" }] instead of ["string"]

  15. useFieldArray SSR ID Mismatch - #12782: Hydration mismatch warnings with SSR (Remix, Next.js). Field IDs generated on server don't match client. Workaround: Use client-only rendering for field arrays or wait for V8 (uses deterministic key)

  16. Next.js 16 reset() Validation Bug - #13110: Calling form.reset() after Server Actions submission causes validation errors on next submit. Fixed in v7.65.0+. Before fix: Use setValue() instead of reset()

  17. Validation Race Condition - #13156: During resolver validation, intermediate render where isValidating=false but errors not populated yet. Don't derive validity from errors alone. Use: !errors.field && !isValidating

  18. ZodError Thrown in Beta Versions - #12816: Zod v4 beta versions throw ZodError directly instead of capturing in formState.errors. Fixed in stable Zod v4.1.x+. Avoid beta versions

  19. Large Form Performance - #13129: 300+ fields with resolver + formState read freezes for 10-15 seconds. See Performance section for 4 workarounds

  20. shadcn Form Import Confusion - IDEs/AI may auto-import Form from "react-hook-form" instead of shadcn. Always import Form components from @/components/ui/form


Upcoming Changes in V8 (Beta)

React Hook Form v8 (currently in beta as of v8.0.0-beta.1, released 2026-01-11) introduces breaking changes. RFC Discussion #7433

Breaking Changes:

  1. useFieldArray: id → key:
// V7:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.id}>...</div>)

// V8:
const { fields } = useFieldArray({ control, name: "items" });
fields.map(field => <div key={field.key}>...</div>)
// keyName prop removed
  1. Watch component: names → name:
// V7:
<Watch names={["email", "password"]} />

// V8:
<Watch name={["email", "password"]} />
  1. watch() callback API removed:
// V7:
watch((data, { name, type }) => {
  console.log(data, name, type);
});

// V8: Use useWatch or manual subscription
const data = useWatch({ control });
useEffect(() => {
  console.log(data);
}, [data]);
  1. setValue() no longer updates useFieldArray:
// V7:
setValue("items", newArray); // Updates field array

// V8: Must use replace() API
const { replace } = useFieldArray({ control, name: "items" });
replace(newArray);

V8 Benefits:

  • Fixes SSR hydration mismatch (deterministic key instead of random id)
  • Improved performance
  • Better TypeScript inference

Migration Timeline: V8 is in beta. Stable release date TBD. Monitor releases for stable version.


Bundled Resources

Templates: basic-form.tsx, advanced-form.tsx, shadcn-form.tsx, server-validation.ts, async-validation.tsx, dynamic-fields.tsx, multi-step-form.tsx, package.json

References: zod-schemas-guide.md, rhf-api-reference.md, error-handling.md, performance-optimization.md, shadcn-integration.md, top-errors.md

Docs: https://react-hook-form.com/ | https://zod.dev/ | https://ui.shadcn.com/docs/components/form


License: MIT | Last Verified: 2026-01-20 | Skill Version: 2.1.0 | Changes: Added 8 new known issues (Zod v4 optional fields bug, useFieldArray primitives limitation, SSR hydration mismatch, performance guidance for large forms, Next.js 16 reset() bug, validation race condition, ZodError thrown in beta, shadcn import confusion), added Zod v4.3.0 features (.exactOptional(), .xor(), z.fromJSONSchema()), added conditional field patterns with shouldUnregister, added V8 beta breaking changes section, expanded Zod v4 resolver compatibility notes, updated to react-hook-form@7.71.1

Related skills

More from jezweb/claude-skills and the wider catalog.

REreact-native logo

react-native

jezweb/claude-skills

React Native and Expo patterns for building performant mobile apps. Covers list performance, animations with Reanimated, navigation, UI patterns, state management, platform-specific code, and Expo workflows. Use when building or reviewing React Native code. Triggers: 'react native', 'expo', 'mobile app', 'react native performance', 'flatlist', 'reanimated', 'expo router', 'mobile development', 'ios app', 'android app'.

984 installsAudited
REreact-native-expo logo

react-native-expo

jezweb/claude-skills

|

751 installs
REreact-patterns logo

react-patterns

jezweb/claude-skills

React 19 performance patterns and composition architecture for Vite + Cloudflare projects. 50+ rules ranked by impact — eliminating waterfalls, bundle optimisation, re-render prevention, composition over boolean props, server/client boundaries, and React 19 APIs. Use when writing, reviewing, or refactoring React components. Triggers: 'react patterns', 'react review', 'react performance', 'optimise components', 'react best practices', 'composition patterns', 'why is it slow', 'reduce re-renders', 'fix waterfall'.

952 installsAudited
REresponsiveness-check logo

responsiveness-check

jezweb/claude-skills

Test website responsiveness across viewport widths with automated screenshots and layout transition detection.

1.7k installs
REresume-cover-letter logo

resume-cover-letter

jezweb/claude-skills

Write a resume / CV or cover letter tailored to a specific role. Handles regional format differences (AU/NZ, US, UK), ATS-friendly formatting, achievement-focused bullets, and cover letter structure. Use whenever the user mentions a job application, resume, CV, cover letter, career document, applying for a role, or needs help framing their experience for a specific job.

825 installs
ROroadmap logo

roadmap

jezweb/claude-skills

Plan and execute entire application builds. Generates phased delivery roadmaps, then executes them autonomously — phase by phase, committing at milestones, deploying, testing, and continuing until done or stuck. Modes: plan (generate roadmap), start (begin executing), resume (continue from where you left off), status (show progress). Triggers: 'roadmap', 'start building', 'resume the build', 'keep going', 'build the whole thing', 'execute the roadmap', 'what phase are we on'.

820 installs