mcpbeat

React Hook Form Zod

secondsky/react-hook-form-zod

Type-safe React forms with React Hook Form and Zod validation. Use for form schemas, field arrays, multi-step forms, or encountering validation errors, resolver issues, nested field problems.

40k tokens
context cost
the whole folder, loaded on every use
19
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
202
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/secondsky/claude-skills --skill react-hook-form-zod

The instruction itself

39 sections, as written by the author

React Hook Form + Zod Validation

Status: Production Ready ✅

Last Updated: 2025-11-21

Dependencies: None (standalone)

Latest Versions: [email protected], [email protected], @hookform/[email protected]


Quick Start (10 Minutes)

1. Install Packages

Why These Packages:

  • react-hook-form: Performant, flexible forms with minimal re-renders
  • zod: TypeScript-first schema validation with type inference
  • @hookform/resolvers: Adapter connecting Zod to React Hook Form

2. Create Your First Form

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// 1. Define validation schema
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

// 2. Infer TypeScript type from schema
type LoginFormData = z.infer<typeof loginSchema>

function LoginForm() {
  // 3. Initialize form with zodResolver
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginFormData>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  })

  // 4. Handle form submission
  const onSubmit = async (data: LoginFormData) => {
    // Data is guaranteed to be valid here
    console.log('Valid data:', data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && (
          <span role="alert" className="error">
            {errors.email.message}
          </span>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && (
          <span role="alert" className="error">
            {errors.password.message}
          </span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  )
}

CRITICAL:

  • Always set defaultValues to prevent "uncontrolled to controlled" warnings
  • Use zodResolver(schema) to connect Zod validation
  • Type form with z.infer<typeof schema> for full type safety
  • Validate on both client AND server (never trust client validation alone)

Template: See templates/basic-form.tsx for complete working example

3. Add Server-Side Validation

// server/api/login.ts
import { z } from 'zod'

// SAME schema on server
const loginSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

export async function loginHandler(req: Request) {
  try {
    const data = loginSchema.parse(await req.json())
    // Data is type-safe and validated
    return { success: true }
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { success: false, errors: error.flatten().fieldErrors }
    }
    throw error
  }
}

Why Server Validation:

  • Client validation can be bypassed (inspect element, Postman, curl)
  • Server validation is your security layer
  • Same Zod schema = single source of truth

Template: See templates/server-validation.ts


Core Concepts

useForm Hook

const {
  register,           // Register input fields
  handleSubmit,       // Wrap onSubmit handler
  formState,          // Form state (errors, isValid, isDirty, etc.)
  setValue,           // Set field value programmatically
  getValues,          // Get current form values
  watch,              // Watch field values
  reset,              // Reset form to defaults
  trigger,            // Trigger validation manually
  control,            // For Controller/useController
} = useForm<FormData>({
  resolver: zodResolver(schema),
  mode: 'onSubmit',               // When to validate
  defaultValues: {},              // Initial values (REQUIRED)
})

Validation Modes:

  • onSubmit - Validate on submit (best performance)
  • onChange - Validate on every change (live feedback)
  • onBlur - Validate when field loses focus (good balance)
  • all - Validate on submit, blur, and change

Reference: See references/rhf-api-reference.md for complete API

Zod Schema Basics

import { z } from 'zod'

// Basic types
const schema = z.object({
  email: z.string().email('Invalid email'),
  age: z.number().min(18, 'Must be 18+'),
  terms: z.boolean().refine(val => val === true, 'Must accept terms'),
})

// Nested objects
const addressSchema = z.object({
  user: z.object({
    name: z.string(),
    email: z.string().email(),
  }),
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string().regex(/^\d{5}$/),
  }),
})

// Arrays
const tagsSchema = z.object({
  tags: z.array(z.string()).min(1, 'At least one tag required'),
})

// Optional and nullable
const optionalSchema = z.object({
  middleName: z.string().optional(),
  nickname: z.string().nullable(),
  bio: z.string().nullish(), // optional AND nullable
})

Reference: See references/zod-schemas-guide.md for complete patterns


Critical Rules

Always Do

Always set defaultValues - Prevents "uncontrolled to controlled" warnings

Use zodResolver for validation - Connects Zod schemas to React Hook Form

Infer types from schema - Use z.infer<typeof schema> for type safety

Validate on server too - Client validation can be bypassed

Use .register() for native inputs - Simple and performant

Use Controller for custom components - For component libraries (MUI, Chakra, etc.)

Handle errors accessibly - Use role="alert" for screen readers

Reset form after submission - Use reset() to clear form state

Form Patterns: See templates/ for:

  • basic-form.tsx - Simple login/register forms
  • advanced-form.tsx - Nested objects, arrays, dynamic fields
  • shadcn-form.tsx - Integration with shadcn/ui
  • multi-step-form.tsx - Wizard/stepper forms
  • async-validation.tsx - Async field validation

Never Do

Never skip defaultValues - Causes "uncontrolled to controlled" errors

Never use only client validation - Security vulnerability

Never mutate form values directly - Use setValue() instead

Never ignore accessibility - Always use proper labels and ARIA

Never forget to disable submit when isSubmitting - Prevents double submissions

Performance: See references/performance-optimization.md for:

  • When to use mode: 'onBlur' vs 'onChange'
  • useWatch vs watch()
  • Re-render optimization strategies

Accessibility: See references/accessibility.md for:

  • Proper label association
  • Error announcement
  • Focus management
  • Keyboard navigation

Top 5 Critical Errors

Error #1: Uncontrolled to Controlled Warning ⚠️

Error:

Warning: A component is changing an uncontrolled input to be controlled

Cause: Not setting defaultValues

Solution:

// ❌ BAD
const form = useForm()

// ✅ GOOD
const form = useForm({
  defaultValues: {
    email: '',
    password: '',
  }
})

Error #2: Zod v4 Type Inference Issues

Error: Type inference doesn't work correctly

Solution:

// Explicitly type useForm if needed
const form = useForm<z.infer<typeof schema>>({
  resolver: zodResolver(schema),
})

Source: GitHub Issue #13109


Error #3: Resolver Not Found

Error:

Module not found: Can't resolve '@hookform/resolvers/zod'

Solution:

# Install the resolvers package
bun add @hookform/[email protected]

Error #4: Array Field Issues

Error: Dynamic array fields not working with useFieldArray

Solution:

const { fields, append, remove } = useFieldArray({
  control,
  name: "items" // Must match schema field name exactly
})

Template: See templates/dynamic-fields.tsx


Error #5: Custom Component Validation Fails

Error: Third-party component (MUI, Chakra) doesn't validate

Solution:

Use Controller instead of register:

<Controller
  name="date"
  control={control}
  render={({ field }) => (
    <DatePicker {...field} />
  )}
/>

Reference: See references/error-handling.md for all patterns


All 12 Errors: See references/top-errors.md for complete documentation


Common Patterns

Basic Form

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  name: z.string().min(1, 'Name required'),
  email: z.string().email('Invalid email'),
})

type FormData = z.infer<typeof schema>

function MyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
    defaultValues: { name: '', email: '' }
  })

  const onSubmit = (data: FormData) => console.log(data)

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}
      <button type="submit">Submit</button>
    </form>
  )
}

Template: See templates/basic-form.tsx


Dynamic Fields (useFieldArray)

import { useForm, useFieldArray } from 'react-hook-form'

const schema = z.object({
  items: z.array(
    z.object({
      name: z.string(),
      quantity: z.number().min(1)
    })
  ).min(1, 'At least one item required')
})

function DynamicForm() {
  const { control, handleSubmit } = useForm({
    resolver: zodResolver(schema),
    defaultValues: { items: [{ name: '', quantity: 1 }] }
  })

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

  return (
    <form>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} />
          <button onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button onClick={() => append({ name: '', quantity: 1 })}>
        Add Item
      </button>
    </form>
  )
}

Template: See templates/dynamic-fields.tsx


Async Validation

const schema = z.object({
  username: z.string()
    .min(3)
    .refine(async (username) => {
      const response = await fetch(`/api/check-username?username=${username}`)
      const { available } = await response.json()
      return available
    }, 'Username already taken')
})

Template: See templates/async-validation.tsx


Multi-Step Form

function MultiStepForm() {
  const [step, setStep] = useState(1)
  const form = useForm({
    resolver: zodResolver(schema),
    mode: 'onBlur' // Validate each step before proceeding
  })

  const onSubmit = async (data) => {
    if (step < 3) {
      setStep(step + 1)
    } else {
      // Final submission
      await submitForm(data)
    }
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {step === 1 && <Step1Fields />}
      {step === 2 && <Step2Fields />}
      {step === 3 && <Step3Fields />}
      <button type="submit">
        {step < 3 ? 'Next' : 'Submit'}
      </button>
    </form>
  )
}

Template: See templates/multi-step-form.tsx


shadcn/ui Integration

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'

function ShadcnForm() {
  const form = useForm({
    resolver: zodResolver(schema),
    defaultValues: { email: '' }
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
      </form>
    </Form>
  )
}

Reference: See references/shadcn-integration.md for complete patterns

Template: See templates/shadcn-form.tsx


Using Bundled Resources

Templates (templates/)

Copy-paste ready examples:

  • basic-form.tsx - Simple login/register forms with validation
  • advanced-form.tsx - Nested objects, arrays, conditional fields
  • shadcn-form.tsx - shadcn/ui Form component integration
  • multi-step-form.tsx - Wizard/stepper forms with step validation
  • dynamic-fields.tsx - useFieldArray for dynamic form fields
  • async-validation.tsx - Async field validation (username check, etc.)
  • server-validation.ts - Server-side validation with Zod
  • custom-error-display.tsx - Custom error message components
  • package.json - Package versions and scripts

References (references/)

Detailed documentation:

  • top-errors.md - All 12 common errors with solutions and sources
  • rhf-api-reference.md - Complete React Hook Form API reference
  • zod-schemas-guide.md - Comprehensive Zod schema patterns
  • shadcn-integration.md - shadcn/ui Form integration guide
  • error-handling.md - Error display patterns and accessibility
  • performance-optimization.md - Re-render optimization strategies
  • accessibility.md - WCAG compliance and screen reader support
  • links-to-official-docs.md - Organized official documentation links

When to Load References

| Reference | Load When... |

|-----------|--------------|

| top-errors.md | Debugging validation issues, type errors, or "uncontrolled to controlled" warnings |

| rhf-api-reference.md | Need complete API for useForm, register, Controller, formState |

| zod-schemas-guide.md | Building complex schemas (nested, arrays, conditional, async validation) |

| shadcn-integration.md | Using shadcn/ui Form, FormField, FormItem components |

| error-handling.md | Custom error display, validation timing, error message patterns |

| performance-optimization.md | Form re-renders too much, optimizing watch/useWatch |

| accessibility.md | WCAG compliance, screen readers, keyboard navigation |

| links-to-official-docs.md | Need official documentation links |


Performance Tips

Quick Tips:

  • Use mode: 'onBlur' for balance between UX and performance
  • Use useWatch instead of watch() for specific fields
  • Memoize validation schemas outside component
  • Use shouldUnregister: false for conditional fields
  • Avoid watch() without arguments (watches all fields)

Reference: See references/performance-optimization.md for complete strategies


Accessibility

Quick Checklist:

  • ✅ Use <label htmlFor="fieldId"> for all inputs
  • ✅ Add role="alert" to error messages
  • ✅ Use aria-invalid="true" on invalid fields
  • ✅ Ensure keyboard navigation works (Tab, Enter, Escape)
  • ✅ Provide clear, actionable error messages

Reference: See references/accessibility.md for WCAG compliance guide


Validation Schemas (Zod)

Common Patterns:

// Email
z.string().email('Invalid email')

// Password (min 8 chars, 1 uppercase, 1 number)
z.string()
  .min(8)
  .regex(/[A-Z]/, 'Need uppercase')
  .regex(/[0-9]/, 'Need number')

// URL
z.string().url('Invalid URL')

// Date
z.string().datetime() // ISO 8601
z.date() // JS Date object

// File upload
z.instanceof(File)
  .refine(file => file.size <= 5000000, 'Max 5MB')
  .refine(
    file => ['image/jpeg', 'image/png'].includes(file.type),
    'Only JPEG/PNG allowed'
  )

// Custom validation
z.string().refine(
  val => val !== 'admin',
  'Username "admin" is reserved'
)

// Async validation
z.string().refine(
  async (username) => {
    const available = await checkUsername(username)
    return available
  },
  'Username already taken'
)

Reference: See references/zod-schemas-guide.md for all patterns


Dependencies

Required:

Optional:

  • @radix-ui/react-label@latest - For shadcn/ui integration
  • class-variance-authority@latest - For shadcn/ui styling

Official Documentation

  • React Hook Form: https://react-hook-form.com/
  • Zod: https://zod.dev/
  • @hookform/resolvers: https://github.com/react-hook-form/resolvers
  • shadcn/ui Form: https://ui.shadcn.com/docs/components/form
  • GitHub: https://github.com/react-hook-form/react-hook-form

Reference: See references/links-to-official-docs.md for organized links


Troubleshooting

"Uncontrolled to controlled" warning

Solution: Always set defaultValues → See references/top-errors.md #2

Type inference issues with Zod v4

Solution: Explicitly type useForm<z.infer<typeof schema>> → See references/top-errors.md #1

Resolver not found error

Solution: Install @hookform/resolvers package → See references/top-errors.md #3

Custom component doesn't validate

Solution: Use Controller instead of register → See references/top-errors.md #5

Form re-renders too much

Solution: Use mode: 'onBlur' and useWatch → See references/performance-optimization.md


Production Example

This skill is based on production patterns from:

  • Real-world forms: Login, registration, checkout, multi-step wizards
  • Validation: Client + server with shared Zod schemas
  • Accessibility: WCAG 2.1 AA compliant
  • Performance: Optimized for minimal re-renders

Token Savings: ~60% (comprehensive form patterns with templates)

Error Prevention: 100% (all 12 documented issues with solutions)

Ready for production!

How to use it

Copy the folder

Take secondsky/react-hook-form-zod from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.