rhf-zod

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

RHF + Zod Rules

RHF + Zod 表单规则

Core Laws

核心原则

  • Every form uses RHF — no
    useState
    for field values
  • One form per file, one schema per form, all validation via Zod in
    .schema.ts
  • Controller
    pattern only — never
    register
    on custom inputs
  • Never write manual types — always
    z.infer<typeof schema>
  • Always:
    noValidate
    ,
    defaultValues
    ,
    handleSubmit
  • 所有表单均使用RHF — 绝不使用
    useState
    管理字段值
  • 一个文件对应一个表单,一个表单对应一个Schema,所有验证逻辑通过Zod写在
    .schema.ts
    文件中
  • 仅使用
    Controller
    模式 — 自定义输入组件绝不使用
    register
  • 绝不手动编写类型 — 始终使用
    z.infer<typeof schema>
    自动生成
  • 务必包含:
    noValidate
    defaultValues
    handleSubmit

File Structure

文件结构

src/features/<feature>/
  schemas/login.schema.ts       # Zod schema + z.infer export
  forms/LoginForm.tsx           # One form component
  services/auth.service.ts      # API/mutation logic

src/components/inputs/
  ControlledInput.tsx           # Reusable controlled wrappers (ControlledX.tsx)
src/features/<feature>/
  schemas/login.schema.ts       # Zod schema + z.infer 导出
  forms/LoginForm.tsx           # 单个表单组件
  services/auth.service.ts      # API/突变逻辑

src/components/inputs/
  ControlledInput.tsx           # 可复用受控包装组件(命名为ControlledX.tsx)

Schema Pattern

Schema 模式

ts
// login.schema.ts
import { z } from "zod";
export const loginSchema = z.object({
  email: z.string().min(1, "Required").email("Enter a valid email"),
  password: z.string().min(1, "Required").min(8, "Min 8 characters"),
});
export type LoginFormValues = z.infer<typeof loginSchema>;
  • .min(1, "...")
    not
    .nonempty()
    — user-friendly messages on every validator
  • Cross-field:
    .refine()
    /
    .superRefine()
    at schema root
  • Shared fields: extract base schema →
    .extend()
    /
    .merge()
  • Coercion:
    .transform()
    (trim, parse numbers)
ts
// Cross-field example
export const registerSchema = z
  .object({
    password: z.string().min(8, "Min 8 characters"),
    confirmPassword: z.string().min(1, "Required"),
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });
ts
// login.schema.ts
import { z } from "zod";
export const loginSchema = z.object({
  email: z.string().min(1, "Required").email("Enter a valid email"),
  password: z.string().min(1, "Required").min(8, "Min 8 characters"),
});
export type LoginFormValues = z.infer<typeof loginSchema>;
  • 使用
    .min(1, "...")
    而非
    .nonempty()
    — 每个验证器都要有友好的用户提示信息
  • 跨字段验证:在Schema根节点使用
    .refine()
    /
    .superRefine()
  • 共享字段:提取基础Schema后通过
    .extend()
    /
    .merge()
    扩展
  • 类型转换:使用
    .transform()
    (去除空格、解析数字等)
ts
// 跨字段示例
export const registerSchema = z
  .object({
    password: z.string().min(8, "Min 8 characters"),
    confirmPassword: z.string().min(1, "Required"),
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });

Form Component Pattern

表单组件模式

tsx
const LoginForm = ({ onSubmit }) => {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: "", password: "" },
  });
  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <ControlledInput
        name="email"
        control={control}
        label="Email"
        error={errors.email}
      />
      <ControlledInput
        name="password"
        control={control}
        label="Password"
        type="password"
        error={errors.password}
      />
      {errors.root && <div role="alert">{errors.root.message}</div>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Signing in..." : "Sign In"}
      </button>
    </form>
  );
};
tsx
const LoginForm = ({ onSubmit }) => {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
    setError,
  } = useForm({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: "", password: "" },
  });
  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <ControlledInput
        name="email"
        control={control}
        label="Email"
        error={errors.email}
      />
      <ControlledInput
        name="password"
        control={control}
        label="Password"
        type="password"
        error={errors.password}
      />
      {errors.root && <div role="alert">{errors.root.message}</div>}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Signing in..." : "Sign In"}
      </button>
    </form>
  );
};

ControlledInput Wrapper

ControlledInput 包装组件

tsx
const ControlledInput = ({
  name,
  control,
  label,
  type = "text",
  placeholder,
  error,
  ...rest
}) => (
  <div>
    {label && <label htmlFor={name}>{label}</label>}
    <Controller
      name={name}
      control={control}
      render={({ field }) => (
        <input
          {...field}
          id={name}
          type={type}
          placeholder={placeholder}
          aria-invalid={!!error}
          aria-describedby={error ? `${name}-error` : undefined}
          {...rest}
        />
      )}
    />
    {error && (
      <span id={`${name}-error`} role="alert">
        {error.message}
      </span>
    )}
  </div>
);
Non-standard APIs — map manually:
tsx
<Controller
  name="category"
  control={control}
  render={({ field }) => (
    <CustomSelect
      value={field.value}
      onSelect={(v) => field.onChange(v)}
      onBlur={field.onBlur}
    />
  )}
/>
tsx
const ControlledInput = ({
  name,
  control,
  label,
  type = "text",
  placeholder,
  error,
  ...rest
}) => (
  <div>
    {label && <label htmlFor={name}>{label}</label>}
    <Controller
      name={name}
      control={control}
      render={({ field }) => (
        <input
          {...field}
          id={name}
          type={type}
          placeholder={placeholder}
          aria-invalid={!!error}
          aria-describedby={error ? `${name}-error` : undefined}
          {...rest}
        />
      )}
    />
    {error && (
      <span id={`${name}-error`} role="alert">
        {error.message}
      </span>
    )}
  </div>
);
非标准API需手动映射:
tsx
<Controller
  name="category"
  control={control}
  render={({ field }) => (
    <CustomSelect
      value={field.value}
      onSelect={(v) => field.onChange(v)}
      onBlur={field.onBlur}
    />
  )}
/>

Error Handling

错误处理

  • Field-level:
    errors.<field>.message
    next to input
  • Server errors:
    setError("root", { message: "..." })
    errors.root?.message
  • A11y:
    aria-invalid
    ,
    aria-describedby
    ,
    role="alert"
    ,
    <label htmlFor>
    +
    id
    on every input
  • shouldFocusError: true
    (default) — auto-focuses first errored field
  • 字段级错误:在输入框旁显示
    errors.<field>.message
  • 服务器错误:使用
    setError("root", { message: "..." })
    → 显示
    errors.root?.message
  • 无障碍(A11y):为输入框添加
    aria-invalid
    aria-describedby
    role="alert"
    ,每个输入框都要有
    <label htmlFor>
    和对应的
    id
  • shouldFocusError: true
    (默认值)— 自动聚焦第一个错误字段

Submission

提交处理

tsx
const onSubmit = (data) => {
  mutate(data, {
    onError: () => form.setError("root", { message: "Login failed" }),
  });
};
  • Thin submit handlers — delegate to service/mutation layer
  • Disable button during
    isSubmitting
    ,
    reset()
    after success when needed
  • onSubmit
    prop for modal/wizard forms; direct service call for self-contained forms
tsx
const onSubmit = (data) => {
  mutate(data, {
    onError: () => form.setError("root", { message: "Login failed" }),
  });
};
  • 精简提交处理函数 — 将逻辑委托给服务/突变层
  • isSubmitting
    状态下禁用按钮,成功后按需调用
    reset()
  • 模态框/向导表单使用
    onSubmit
    属性;独立表单直接调用服务

Multi-Step Forms

多步骤表单

  • Each step = own component + own schema file
  • Aggregate step data via context/zustand/parent
  • Validate each step independently on "Next"; final submit against combined schema
  • 每个步骤对应独立组件和独立Schema文件
  • 通过上下文/zustand/父组件聚合步骤数据
  • 点击“下一步”时独立验证当前步骤;最终提交时验证合并后的Schema

Dynamic / Array Fields

动态/数组字段

tsx
const { fields, append, remove } = useFieldArray({ control, name: "items" });
// key={field.id} — never key on index
{
  fields.map((field, i) => (
    <div key={field.id}>
      <ControlledInput
        name={`items.${i}.description`}
        control={control}
        label="Desc"
      />
      <button type="button" onClick={() => remove(i)}>
        Remove
      </button>
    </div>
  ));
}
<button type="button" onClick={() => append({ description: "", qty: 1 })}>
  Add
</button>;
Schema:
z.array(z.object({ description: z.string(), qty: z.number() }))
tsx
const { fields, append, remove } = useFieldArray({ control, name: "items" });
// 使用key={field.id} — 绝不使用索引作为key
{
  fields.map((field, i) => (
    <div key={field.id}>
      <ControlledInput
        name={`items.${i}.description`}
        control={control}
        label="Desc"
      />
      <button type="button" onClick={() => remove(i)}>
        Remove
      </button>
    </div>
  ));
}
<button type="button" onClick={() => append({ description: "", qty: 1 })}>
  Add
</button>;
Schema示例:
z.array(z.object({ description: z.string(), qty: z.number() }))

FormProvider (≥3 nesting levels only)

FormProvider(仅用于嵌套层级≥3的情况)

tsx
// Parent
<FormProvider {...methods}>
  <form onSubmit={methods.handleSubmit(onSubmit)} noValidate>
    ...
  </form>
</FormProvider>;
// Deep child
const {
  control,
  formState: { errors },
} = useFormContext();
tsx
// 父组件
<FormProvider {...methods}>
  <form onSubmit={methods.handleSubmit(onSubmit)} noValidate>
    ...
  </form>
</FormProvider>;
// 深层子组件
const {
  control,
  formState: { errors },
} = useFormContext();

Performance

性能优化

  • useWatch({ name: "field" })
    — never
    watch()
    with no args
  • Extract heavy input groups into sub-components (Controller scopes re-renders)
  • useMemo
    for expensive derived values
  • 使用
    useWatch({ name: "field" })
    — 绝不调用无参数的
    watch()
  • 将复杂输入组提取为子组件(Controller会限定重渲染范围)
  • 对昂贵的派生值使用
    useMemo

Anti-Patterns

反模式

❌ Don't✅ Do
useState
for fields
useForm
register()
on custom input
Controller
Inline schema in component
.schema.ts
file
Manual
type FormValues
z.infer<typeof schema>
Validation in
onSubmit
Zod schema
Multiple forms in one fileOne form per file
watch()
no args
useWatch({ name })
Missing
defaultValues
Always provide them
Missing
noValidate
Always add it
No
aria-*
on errors
aria-invalid
+
aria-describedby
+
role="alert"
No submit guardDisable during
isSubmitting
❌ 禁止做法✅ 推荐做法
使用
useState
管理字段值
使用
useForm
在自定义输入组件上使用
register()
使用
Controller
在组件内定义内联Schema将Schema写在
.schema.ts
文件中
手动定义
type FormValues
类型
使用
z.infer<typeof schema>
自动生成
onSubmit
中进行验证
使用Zod Schema进行验证
一个文件中包含多个表单一个文件对应一个表单
调用无参数的
watch()
使用
useWatch({ name })
未提供
defaultValues
务必提供默认值
未添加
noValidate
务必添加该属性
错误提示未添加
aria-*
属性
添加
aria-invalid
+
aria-describedby
+
role="alert"
未添加提交防护
isSubmitting
状态下禁用按钮

Dependencies

依赖安装

bash
npm install react-hook-form zod @hookform/resolvers
bash
npm install react-hook-form zod @hookform/resolvers

react-hook-form@^7.50 zod@^3.22 @hookform/resolvers@^3.3

react-hook-form@^7.50 zod@^3.22 @hookform/resolvers@^3.3

undefined
undefined

New Form Checklist

新表单检查清单

  • schemas/form-name.schema.ts
    — Zod schema +
    z.infer
    type
  • forms/FormName.tsx
    useForm
    +
    zodResolver
    +
    defaultValues
  • Controller
    wrappers for all inputs
  • noValidate
    ,
    aria-*
    errors,
    isSubmitting
    guard
  • Server errors via
    setError("root")
  • Thin submit → service/mutation layer
  • schemas/form-name.schema.ts
    — Zod Schema +
    z.infer
    类型导出
  • forms/FormName.tsx
    — 包含
    useForm
    +
    zodResolver
    +
    defaultValues
  • 所有输入组件均使用
    Controller
    包装
  • 包含
    noValidate
    、无障碍错误属性、
    isSubmitting
    防护
  • 通过
    setError("root")
    处理服务器错误
  • 精简提交逻辑 → 委托给服务/突变层