mcpField
R
react-hook-form-zod

react-hook-form-zod

github.com/ovachiever开发56723 次安装49 周前更新
代码仓库

关于 react-hook-form-zod

面向开发者的 React 表单实践指南,介绍如何结合 React Hook Form 与 Zod。内容涵盖模式验证、TypeScript 类型推断、字段模式及进阶表单流程。

主要功能

  1. 01通过 zodResolver 进行 Zod 模式验证
  2. 02使用 z.infer 推断类型
  3. 03标准字段与受控字段模式
  4. 04动态表单与多步骤表单
  5. 05错误处理与无障碍建议

适用场景

  1. 01用共享模式验证表单字段
  2. 02将自定义输入接入 React Hook Form
  3. 03构建条件字段或重复字段表单

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/ovachiever/droid-tings --skill react-hook-form-zod

安装后在 Claude Code 中输入/react-hook-form-zod确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

README.md
.claude-plugin/
plugin.json
references/
accessibility.md
error-handling.md
links-to-official-docs.md
performance-optimization.md
rhf-api-reference.md
shadcn-integration.md
top-errors.md
zod-schemas-guide.md
scripts/
check-versions.sh
templates/
advanced-form.tsx
async-validation.tsx
basic-form.tsx
custom-error-display.tsx
dynamic-fields.tsx
multi-step-form.tsx
package.json
server-validation.ts
shadcn-form.tsx

详细介绍

React Hook Form + Zod 验证

状态:已可用于生产环境 ✅ 最后更新:2025-11-20 依赖项:无(独立使用) 最新版本:[email protected]、[email protected]、@hookform/[email protected]


快速开始(10 分钟)

1. 安装软件包
npm install [email protected] [email protected] @hookform/[email protected]

选择这些软件包的原因:

  • react-hook-form:高性能、灵活的表单库,重渲染次数极少
  • zod:以 TypeScript 为先的模式验证,支持类型推断
  • @hookform/resolvers:用于将 Zod(及其他验证器)连接到 React Hook Form 的适配器
2. 创建你的第一个表单
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)
    // Make API call, etc.
  }

  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>
  )
}

重要:

  • 始终设置 defaultValues,以避免出现“从非受控变为受控”的警告
  • 使用 zodResolver(schema) 连接 Zod 验证
  • 使用 z.infer<typeof schema> 为表单添加类型,实现完整的类型安全
  • 在客户端和服务端都进行验证(绝不要只信任客户端验证)
3. 添加服务端验证
// 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 {
    // Parse and validate request body
    const data = loginSchema.parse(await req.json())

    // Data is type-safe and validated
    // Proceed with authentication logic
    return { success: true }
  } catch (error) {
    if (error instanceof z.ZodError) {
      // Return validation errors to client
      return { success: false, errors: error.flatten().fieldErrors }
    }
    throw error
  }
}

服务端验证的原因:

  • 客户端验证可以被绕过(检查元素、Postman、curl)
  • 服务端验证是你的安全防线
  • 使用同一个 Zod 模式 = 单一事实来源
  • 在前端和后端实现类型安全

核心概念

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

useForm 选项:

选项描述默认值
resolver验证解析器(例如 zodResolver)undefined
mode何时进行验证('onSubmit'、'onChange'、'onBlur'、'all')'onSubmit'
reValidateMode出错后何时重新验证'onChange'
defaultValues表单初始值{}
shouldUnregister组件卸载时注销输入项false
criteriaMode返回所有错误还是仅返回第一个错误'firstError'

表单验证模式:

  • onSubmit - 提交时验证(性能最佳,响应性较低)
  • onChange - 每次更改时验证(实时反馈,重渲染更多)
  • onBlur - 字段失去焦点时验证(平衡性较好)
  • all - 提交、失焦和更改时都进行验证(响应性最高,开销也最大)
Zod 模式定义
import { z } from 'zod'

// Primitives
const stringSchema = z.string()
const numberSchema = z.number()
const booleanSchema = z.boolean()
const dateSchema = z.date()

// With validation
const emailSchema = z.string().email('Invalid email')
const ageSchema = z.number().min(18, 'Must be 18+').max(120, 'Invalid age')
const usernameSchema = z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/)

// Objects
const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive(),
})

// Arrays
const tagsSchema = z.array(z.string())
const usersSchema = z.array(userSchema)

// Optional and Nullable
const optionalField = z.string().optional()       // string | undefined
const nullableField = z.string().nullable()       // string | null
const nullishField = z.string().nullish()         // string | null | undefined

// Default values
const withDefault = z.string().default('default value')

// Unions
const statusSchema = z.union([
  z.literal('active'),
  z.literal('inactive'),
  z.literal('pending'),
])
// Shorthand for literals
const statusEnum = z.enum(['active', 'inactive', 'pending'])

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

const profileSchema = z.object({
  name: z.string(),
  address: addressSchema,  // Nested object
})

// Custom error messages
const passwordSchema = z.string()
  .min(8, { message: 'Password must be at least 8 characters' })
  .regex(/[A-Z]/, { message: 'Password must contain uppercase letter' })
  .regex(/[0-9]/, { message: 'Password must contain number' })

类型推断:

const userSchema = z.object({
  name: z.string(),
  age: z.number(),
})

// Automatically infer TypeScript type
type User = z.infer<typeof userSchema>
// Result: { name: string; age: number }
Zod 细化(自定义验证)
// Simple refinement
const passwordConfirmSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'], // Error will appear on confirmPassword field
})

// Multiple refinements
const signupSchema = z.object({
  username: z.string(),
  email: z.string().email(),
  age: z.number(),
})
  .refine((data) => data.username !== data.email.split('@')[0], {
    message: 'Username cannot be your email prefix',
    path: ['username'],
  })
  .refine((data) => data.age >= 18, {
    message: 'Must be 18 or older',
    path: ['age'],
  })

// Async refinement (for API checks)
const usernameSchema = z.string().refine(async (username) => {
  // Check if username is available via API
  const response = await fetch(`/api/check-username?username=${username}`)
  const { available } = await response.json()
  return available
}, {
  message: 'Username is already taken',
})
Zod 转换(数据处理)
// Transform string to number
const ageSchema = z.string().transform((val) => parseInt(val, 10))

// Transform to uppercase
const uppercaseSchema = z.string().transform((val) => val.toUpperCase())

// Transform date string to Date object
const dateSchema = z.string().transform((val) => new Date(val))

// Trim whitespace
const trimmedSchema = z.string().transform((val) => val.trim())

// Complex transform
const userInputSchema = z.object({
  email: z.string().email().transform((val) => val.toLowerCase()),
  tags: z.string().transform((val) => val.split(',').map(tag => tag.trim())),
})

// Chain transform and refine
const positiveNumberSchema = z.string()
  .transform((val) => parseFloat(val))
  .refine((val) => !isNaN(val), { message: 'Must be a number' })
  .refine((val) => val > 0, { message: 'Must be positive' })
集成 zodResolver
import { zodResolver } from '@hookform/resolvers/zod'

const form = useForm<FormData>({
  resolver: zodResolver(schema),
})

zodResolver 的作用:

  1. 接收你的 Zod 模式
  2. 将其转换为 React Hook Form 可理解的格式
  3. 提供在表单提交时运行的验证函数
  4. 将 Zod 错误映射为 React Hook Form 的错误格式
  5. 通过 TypeScript 推断保留类型安全

zodResolver 选项:

import { zodResolver } from '@hookform/resolvers/zod'

// With options
const form = useForm({
  resolver: zodResolver(schema, {
    async: false,     // Use async validation
    raw: false,       // Return raw Zod error
  }),
})

表单注册模式

模式 1:注册简单输入项
function BasicForm() {
  const { register, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Spread register result to input */}
      <input {...register('email')} />
      <input {...register('password')} />

      {/* With custom props */}
      <input
        {...register('username')}
        placeholder="Enter username"
        className="input"
      />
    </form>
  )
}

register() 的返回值:

{
  onChange: (e) => void,
  onBlur: (e) => void,
  ref: (instance) => void,
  name: string,
}
模式 2:Controller(用于自定义组件)

当输入项不暴露 ref 时(例如自定义组件、React Select、日期选择器等),请使用 Controller:

import { Controller } from 'react-hook-form'

function FormWithCustomInput() {
  const { control, handleSubmit } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Controller
        name="category"
        control={control}
        render={({ field }) => (
          <CustomSelect
            {...field}                      // value, onChange, onBlur, ref
            options={categoryOptions}
          />
        )}
      />

      {/* With more control */}
      <Controller
        name="dateOfBirth"
        control={control}
        render={({ field, fieldState }) => (
          <div>
            <DatePicker
              selected={field.value}
              onChange={field.onChange}
              onBlur={field.onBlur}
            />
            {fieldState.error && (
              <span>{fieldState.error.message}</span>
            )}
          </div>
        )}
      />
    </form>
  )
}

适用 Controller 的情况:

  • ✅ 第三方 UI 库(React Select、Material-UI、Ant Design 等)
  • ✅ 不暴露 ref 的自定义组件
  • ✅ 不使用 onChange 的组件(例如使用自定义处理程序的复选框)
  • ✅ 需要精细控制字段行为

不适用 Controller 的情况:

  • ❌ 标准 HTML 输入项(改用 register,更简单、更快)
  • ❌ 对性能要求极高时(Controller 会带来少量额外开销)
模式 3:useController(可复用的受控输入项)
import { useController } from 'react-hook-form'

// Reusable custom input component
function CustomInput({ name, control, label }) {
  const {
    field,
    fieldState: { error },
  } = useController({
    name,
    control,
    defaultValue: '',
  })

  return (
    <div>
      <label>{label}</label>
      <input {...field} />
      {error && <span>{error.message}</span>}
    </div>
  )
}

// Usage
function MyForm() {
  const { control, handleSubmit } = useForm({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <CustomInput name="email" control={control} label="Email" />
      <CustomInput name="username" control={control} label="Username" />
    </form>
  )
}

错误处理

显示错误
function FormWithErrors() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('email')} aria-invalid={errors.email ? 'true' : 'false'} />

        {/* Simple error display */}
        {errors.email && <span>{errors.email.message}</span>}

        {/* Accessible error display */}
        {errors.email && (
          <span role="alert" className="error">
            {errors.email.message}
          </span>
        )}

        {/* Error with icon */}
        {errors.email && (
          <div role="alert" className="error">
            <ErrorIcon />
            <span>{errors.email.message}</span>
          </div>
        )}
      </div>
    </form>
  )
}
错误对象结构
// errors object structure
{
  email: {
    type: 'invalid_string',
    message: 'Invalid email address',
  },
  password: {
    type: 'too_small',
    message: 'Password must be at least 8 characters',
  },
  // Nested errors
  address: {
    street: {
      type: 'invalid_type',
      message: 'Expected string, received undefined',
    },
  },
}
表单级验证错误
const schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'], // Attach error to confirmPassword field
})

// Without path - creates root error
.refine((data) => someCondition, {
  message: 'Form validation failed',
})

// Access root errors
const { formState: { errors } } = useForm()
errors.root?.message // Root-level error
集成服务端错误
function FormWithServerErrors() {
  const { register, handleSubmit, setError, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  })

  const onSubmit = async (data) => {
    try {
      const response = await fetch('/api/submit', {
        method: 'POST',
        body: JSON.stringify(data),
      })

      if (!response.ok) {
        const { errors: serverErrors } = await response.json()

        // Map server errors to form fields
        Object.entries(serverErrors).forEach(([field, message]) => {
          setError(field, {
            type: 'server',
            message,
          })
        })

        return
      }

      // Success!
    } catch (error) {
      // Generic error
      setError('root', {
        type: 'server',
        message: 'An error occurred. Please try again.',
      })
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {errors.root && <div role="alert">{errors.root.message}</div>}
      {/* ... */}
    </form>
  )
}

高级模式

动态表单字段(useFieldArray)
import { useForm, useFieldArray } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const contactSchema = z.object({
  contacts: z.array(
    z.object({
      name: z.string().min(1, 'Name is required'),
      email: z.string().email('Invalid email'),
    })
  ).min(1, 'At least one contact is required'),
})

type ContactFormData = z.infer<typeof contactSchema>

function ContactListForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      contacts: [{ name: '', email: '' }],
    },
  })

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

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id}> {/* IMPORTANT: Use field.id, not index */}
          <input
            {...register(`contacts.${index}.name` as const)}
            placeholder="Name"
          />
          {errors.contacts?.[index]?.name && (
            <span>{errors.contacts[index].name.message}</span>
          )}

          <input
            {...register(`contacts.${index}.email` as const)}
            placeholder="Email"
          />
          {errors.contacts?.[index]?.email && (
            <span>{errors.contacts[index].email.message}</span>
          )}

          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ name: '', email: '' })}
      >
        Add Contact
      </button>

      <button type="submit">Submit</button>
    </form>
  )
}

useFieldArray API:

  • fields - 包含唯一 ID 的字段项数组
  • append(value) - 在末尾添加新项
  • prepend(value) - 在开头添加新项
  • insert(index, value) - 在指定索引处插入项
  • remove(index) - 移除指定索引处的项
  • update(index, value) - 更新指定索引处的项
  • replace(values) - 替换整个数组
带防抖的异步验证
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useDebouncedCallback } from 'use-debounce' // npm install use-debounce

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

function AsyncValidationForm() {
  const { register, handleSubmit, trigger, formState: { errors, isValidating } } = useForm({
    resolver: zodResolver(z.object({ username: usernameSchema })),
    mode: 'onChange', // Validate on every change
  })

  // Debounce validation to avoid too many API calls
  const debouncedValidation = useDebouncedCallback(() => {
    trigger('username')
  }, 500) // Wait 500ms after user stops typing

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('username')}
        onChange={(e) => {
          register('username').onChange(e)
          debouncedValidation()
        }}
      />
      {isValidating && <span>Checking availability...</span>}
      {errors.username && <span>{errors.username.message}</span>}
    </form>
  )
}
多步骤表单(向导)
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// Step schemas
const step1Schema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Invalid email'),
})

const step2Schema = z.object({
  address: z.string().min(1, 'Address is required'),
  city: z.string().min(1, 'City is required'),
})

const step3Schema = z.object({
  cardNumber: z.string().regex(/^\d{16}$/, 'Invalid card number'),
  cvv: z.string().regex(/^\d{3,4}$/, 'Invalid CVV'),
})

// Combined schema for final validation
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema)

type FormData = z.infer<typeof fullSchema>

function MultiStepForm() {
  const [step, setStep] = useState(1)

  const { register, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(fullSchema),
    mode: 'onChange',
  })

  const nextStep = async () => {
    let fieldsToValidate: (keyof FormData)[] = []

    if (step === 1) {
      fieldsToValidate = ['name', 'email']
    } else if (step === 2) {
      fieldsToValidate = ['address', 'city']
    }

    // Validate current step fields
    const isValid = await trigger(fieldsToValidate)

    if (isValid) {
      setStep(step + 1)
    }
  }

  const prevStep = () => setStep(step - 1)

  const onSubmit = (data: FormData) => {
    console.log('Final data:', data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Progress indicator */}
      <div className="progress">
        Step {step} of 3
      </div>

      {/* Step 1 */}
      {step === 1 && (
        <div>
          <h2>Personal Information</h2>
          <input {...register('name')} placeholder="Name" />
          {errors.name && <span>{errors.name.message}</span>}

          <input {...register('email')} placeholder="Email" />
          {errors.email && <span>{errors.email.message}</span>}
        </div>
      )}

      {/* Step 2 */}
      {step === 2 && (
        <div>
          <h2>Address</h2>
          <input {...register('address')} placeholder="Address" />
          {errors.address && <span>{errors.address.message}</span>}

          <input {...register('city')} placeholder="City" />
          {errors.city && <span>{errors.city.message}</span>}
        </div>
      )}

      {/* Step 3 */}
      {step === 3 && (
        <div>
          <h2>Payment</h2>
          <input {...register('cardNumber')} placeholder="Card Number" />
          {errors.cardNumber && <span>{errors.cardNumber.message}</span>}

          <input {...register('cvv')} placeholder="CVV" />
          {errors.cvv && <span>{errors.cvv.message}</span>}
        </div>
      )}

      {/* Navigation */}
      <div>
        {step > 1 && (
          <button type="button" onClick={prevStep}>
            Previous
          </button>
        )}
        {step < 3 ? (
          <button type="button" onClick={nextStep}>
            Next
          </button>
        ) : (
          <button type="submit">Submit</button>
        )}
      </div>
    </form>
  )
}
条件验证
import { z } from 'zod'

// Schema with conditional validation
const formSchema = z.discriminatedUnion('accountType', [
  z.object({
    accountType: z.literal('personal'),
    name: z.string().min(1),
  }),
  z.object({
    accountType: z.literal('business'),
    companyName: z.string().min(1),
    taxId: z.string().regex(/^\d{9}$/),
  }),
])

// Alternative: Using refine
const conditionalSchema = z.object({
  hasDiscount: z.boolean(),
  discountCode: z.string().optional(),
}).refine((data) => {
  // If hasDiscount is true, discountCode is required
  if (data.hasDiscount && !data.discountCode) {
    return false
  }
  return true
}, {
  message: 'Discount code is required when discount is enabled',
  path: ['discountCode'],
})

shadcn/ui 集成

使用 Form 组件(旧版)
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'

const formSchema = z.object({
  username: z.string().min(2, 'Username must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
})

function ProfileForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: '',
      email: '',
    },
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Username</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                This is your public display name.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" placeholder="[email protected]" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <button type="submit">Submit</button>
      </form>
    </Form>
  )
}

注意:shadcn/ui 表示:“我们不再积极开发 Form 组件。”他们建议在新的实现中使用 Field 组件。

使用 Field 组件(推荐)

请查看 shadcn/ui 文档,了解最新的 Field 组件 API,因为这是目前仍在维护的方案。


性能优化

表单模式策略
// Best performance - validate only on submit
const form = useForm({
  mode: 'onSubmit',
  resolver: zodResolver(schema),
})

// Good balance - validate on blur
const form = useForm({
  mode: 'onBlur',
  resolver: zodResolver(schema),
})

// Live feedback - validate on every change
const form = useForm({
  mode: 'onChange',
  resolver: zodResolver(schema),
})

// Maximum validation - all events
const form = useForm({
  mode: 'all',
  resolver: zodResolver(schema),
})
受控与非受控输入
// Uncontrolled (better performance) - use register
<input {...register('email')} />

// Controlled (more control) - use Controller
<Controller
  name="email"
  control={control}
  render={({ field }) => <Input {...field} />}
/>

建议:标准输入使用 register,仅在必要时(第三方组件、自定义行为)使用 Controller。

使用 Controller 进行隔离
// BAD: Entire form re-renders when any field changes
function BadForm() {
  const { watch } = useForm()
  const values = watch() // Watches ALL fields

  return <div>{JSON.stringify(values)}</div>
}

// GOOD: Only re-render when specific field changes
function GoodForm() {
  const { watch } = useForm()
  const email = watch('email') // Watches only email field

  return <div>{email}</div>
}
shouldUnregister 标志
const form = useForm({
  resolver: zodResolver(schema),
  shouldUnregister: true, // Remove field data when unmounted
})

适用场景:

  • ✅ 多步骤表单中每个步骤包含不同字段
  • ✅ 不应保留的条件字段
  • ✅ 希望在组件卸载时清除数据

不适用场景:

  • ❌ 希望在切换可见性时保留表单数据
  • ❌ 在表单分区(标签页、折叠面板)之间切换

无障碍最佳实践

ARIA 属性
function AccessibleForm() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          {...register('email')}
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email.message}
          </span>
        )}
      </div>
    </form>
  )
}
错误提示
import { useEffect } from 'react'

function FormWithAnnouncements() {
  const { formState: { errors, isSubmitted } } = useForm()

  // Announce errors to screen readers
  useEffect(() => {
    if (isSubmitted && Object.keys(errors).length > 0) {
      const errorCount = Object.keys(errors).length
      const announcement = `Form submission failed with ${errorCount} error${errorCount > 1 ? 's' : ''}`

      // Create live region for announcement
      const liveRegion = document.createElement('div')
      liveRegion.setAttribute('role', 'alert')
      liveRegion.setAttribute('aria-live', 'assertive')
      liveRegion.textContent = announcement
      document.body.appendChild(liveRegion)

      setTimeout(() => {
        document.body.removeChild(liveRegion)
      }, 1000)
    }
  }, [errors, isSubmitted])

  return (
    <form>
      {/* ... */}
    </form>
  )
}
焦点管理
import { useRef, useEffect } from 'react'

function FormWithFocus() {
  const { handleSubmit, formState: { errors } } = useForm()
  const firstErrorRef = useRef<HTMLInputElement>(null)

  // Focus first error field on validation failure
  useEffect(() => {
    if (Object.keys(errors).length > 0) {
      firstErrorRef.current?.focus()
    }
  }, [errors])

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('email')}
        ref={errors.email ? firstErrorRef : undefined}
      />
    </form>
  )
}

关键规则

始终执行

✅ 设置 defaultValues,防止出现“从非受控变为受控”的警告

const form = useForm({
  defaultValues: { email: '', password: '' }, // ALWAYS set defaults
})

✅ 使用 zodResolver 集成 Zod

const form = useForm({
  resolver: zodResolver(schema), // Required for Zod validation
})

✅ 使用 z.infer 为表单添加类型

type FormData = z.infer<typeof schema> // Automatic type inference

✅ 同时进行客户端和服务端验证

// Client
const form = useForm({ resolver: zodResolver(schema) })

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

✅ 使用 formState.errors 显示错误

{errors.email && <span role="alert">{errors.email.message}</span>}

✅ 添加 ARIA 属性以提升无障碍性

<input
  {...register('email')}
  aria-invalid={errors.email ? 'true' : 'false'}
  aria-describedby="email-error"
/>

✅ 将 field.id 用作 useFieldArray 的键

{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}

✅ 对异步验证进行防抖

const debouncedValidation = useDebouncedCallback(() => trigger('username'), 500)
绝不执行

❌ 跳过服务端验证(安全漏洞!)

// BAD: Only client validation
const form = useForm({ resolver: zodResolver(schema) })
// API endpoint has no validation

// GOOD: Validate on both client and server
const form = useForm({ resolver: zodResolver(schema) })
// API: schema.parse(data) on server too

❌ 使用 Zod v4 却不检查类型推断

// Issue #13109: Zod v4 has type inference changes
// Test your types carefully when upgrading

❌ 在 Controller 中忘记展开 {...field}

// BAD
<Controller render={({ field }) => <Input value={field.value} />} />

// GOOD
<Controller render={({ field }) => <Input {...field} />} />

❌ 直接修改表单值

// BAD
const values = getValues()
values.email = '[email protected]' // Direct mutation

// GOOD
setValue('email', '[email protected]') // Use setValue

❌ 使用内联验证却不进行防抖

// BAD: Validates on every keystroke
const form = useForm({ mode: 'onChange' })

// GOOD: Debounce async validation
const debouncedTrigger = useDebouncedCallback(() => trigger(), 500)

❌ 混用受控和非受控输入

// BAD: Mixing patterns
<input {...register('email')} value={email} onChange={setEmail} />

// GOOD: Choose one pattern
<input {...register('email')} /> // Uncontrolled
// OR
<Controller render={({ field }) => <Input {...field} />} /> // Controlled

❌ 在 useFieldArray 中使用索引作为键

// BAD
{fields.map((field, index) => <div key={index}>{/* ... */}</div>)}

// GOOD
{fields.map((field) => <div key={field.id}>{/* ... */}</div>)}

❌ 忘记为所有字段设置 defaultValues

// BAD: Missing defaults causes warnings
const form = useForm({
  resolver: zodResolver(schema),
})

// GOOD: Set defaults for all fields
const form = useForm({
  resolver: zodResolver(schema),
  defaultValues: { email: '', password: '', remember: false },
})

预防已知问题

此技能可预防 12 个已记录的问题:

问题 #1:Zod v4 类型推断错误

错误:Zod v4 的类型推断无法正常工作 来源:GitHub Issue #13109(于 2025-11-01 关闭) 发生原因:Zod v4 更改了类型推断方式 预防方法:使用正确的类型模式:type FormData = z.infer<typeof schema> 注意:此问题已在 react-hook-form v7.66.x+ 中解决。请升级到最新版本以避免此问题。

问题 #2:从非受控变为受控的警告

错误:“A component is changing an uncontrolled input to be controlled” 来源:React 文档 发生原因:未设置 defaultValues,导致值从 undefined 变为 value 预防方法:始终为所有字段设置 defaultValues

问题 #3:嵌套对象验证错误

错误:嵌套字段的错误无法正确显示 来源:常见的 React Hook Form 问题 发生原因:错误地访问嵌套错误 预防方法:使用可选链:errors.address?.street?.message

问题 #4:数组字段重复渲染

错误:包含数组字段的表单重复渲染过多 来源:性能问题 发生原因:未使用 field.id 作为键 预防方法:在 useFieldArray 映射中使用 key={field.id}

问题 #5:异步验证竞态条件

错误:多个验证请求导致结果冲突 来源:常见的异步模式问题 发生原因:未进行防抖或取消请求 预防方法:对验证进行防抖,并取消待处理的请求

问题 #6:服务端错误映射

错误:服务端验证错误无法映射到表单字段 来源:集成问题 原因:服务端错误格式与 React Hook Form 格式不匹配 预防措施:使用 setError() 将服务端错误映射到字段

问题 #7:未应用默认值

错误:表单字段未显示默认值 来源:常见错误 原因:在表单初始化后设置 defaultValues 预防措施:在 useForm 选项中设置 defaultValues,而不是在 useState 中设置

问题 #8:Controller 字段未更新

错误:值变化时自定义组件未更新 来源:常见的 Controller 问题 原因:未在 render 函数中展开 {...field} 预防措施:始终将 {...field} 展开到自定义组件中

问题 #9:useFieldArray 键警告

错误:React 警告列表中存在重复键 来源:React 列表渲染 原因:使用数组索引作为键,而不是 field.id 预防措施:使用 field.id:key={field.id}

问题 #10:Schema 精化错误路径

错误:自定义验证错误显示在错误的字段上 来源:Zod 精化行为 原因:未在精化选项中指定 path 预防措施:添加 path 选项:refine(..., { message: '...', path: ['fieldName'] })

问题 #11:混淆 transform 与 preprocess

错误:数据转换未按预期工作 来源:混淆 Zod API 原因:为当前用例使用了错误的方法 预防措施:使用 transform 转换输出,使用 preprocess 转换输入

问题 #12:多个 Resolver 冲突

错误:使用多个 resolver 时表单验证无法正常工作 来源:配置错误 原因:尝试使用多个验证库 预防措施:使用单个 resolver(zodResolver),必要时合并 schemas


模板

请参阅 templates/ 目录中的可运行示例:

  1. basic-form.tsx - 简单的登录/注册表单
  2. advanced-form.tsx - 嵌套对象、数组、条件字段
  3. shadcn-form.tsx - shadcn/ui Form 组件集成
  4. server-validation.ts - 使用相同 schema 进行服务端验证
  5. async-validation.tsx - 带防抖的异步验证
  6. dynamic-fields.tsx - 使用 useFieldArray 添加/移除项目
  7. multi-step-form.tsx - 带分步验证的向导
  8. custom-error-display.tsx - 自定义错误格式
  9. package.json - 完整依赖项

参考资料

请参阅 references/ 目录中的深入文档:

  1. zod-schemas-guide.md - 全面的 Zod schema 模式
  2. rhf-api-reference.md - 完整的 React Hook Form API
  3. error-handling.md - 错误消息、格式化、无障碍访问
  4. accessibility.md - WCAG 合规性、ARIA 属性
  5. performance-optimization.md - 表单模式、验证策略
  6. shadcn-integration.md - shadcn/ui Form 与 Field 组件
  7. top-errors.md - 12 个常见错误及解决方案
  8. links-to-official-docs.md - 分类整理的文档链接

官方文档


许可证:MIT 上次验证:2025-11-20 维护者:Jeremy Dawes ([email protected])

评论

支持 Markdown