mcpField
R
react-hook-form-zod

react-hook-form-zod

github.com/jackspace开发1660 次安装13 周前更新
代码仓库

关于 react-hook-form-zod

一份使用 react-hook-form 和 Zod 构建 React 表单的实用指南,适用于需要类型安全验证的开发者。内容涵盖模式验证、TypeScript 类型推断、自定义输入、动态与多步骤表单、错误处理、性能和无障碍实践。

主要功能

  1. 01将 Zod 模式接入 react-hook-form
  2. 02从模式推断 TypeScript 表单类型
  3. 03介绍自定义与动态表单字段
  4. 04说明验证时机与错误处理
  5. 05涵盖性能和无障碍实践

适用场景

  1. 01构建带模式验证的类型安全表单
  2. 02验证自定义输入和动态字段数组
  3. 03实现多步骤或条件表单
  4. 04处理服务端错误并提供无障碍反馈

安装

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

$npx skills add https://github.com/jackspace/claudeskillz --skill react-hook-form-zod

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

附带文件README.mdSKILL.json

详细介绍

React Hook Form + Zod 验证

状态:已可用于生产环境 ✅ 最后更新:2025-10-23 依赖项:无(独立) 最新版本:[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 发生原因:Zod v4 更改了类型推断方式 预防方法:使用正确的类型模式:type FormData = z.infer<typeof schema>

问题 #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 的 map 中使用 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),如有需要则组合多个 schema


模板

请参阅 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-10-23 维护者:Jeremy Dawes ([email protected])

评论

支持 Markdown