rhf-zod
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseRHF + Zod Rules
RHF + Zod 表单规则
Core Laws
核心原则
- Every form uses RHF — no for field values
useState - One form per file, one schema per form, all validation via Zod in
.schema.ts - pattern only — never
Controlleron custom inputsregister - Never write manual types — always
z.infer<typeof schema> - Always: ,
noValidate,defaultValueshandleSubmit
- 所有表单均使用RHF — 绝不使用管理字段值
useState - 一个文件对应一个表单,一个表单对应一个Schema,所有验证逻辑通过Zod写在文件中
.schema.ts - 仅使用模式 — 自定义输入组件绝不使用
Controllerregister - 绝不手动编写类型 — 始终使用自动生成
z.infer<typeof schema> - 务必包含:、
noValidate、defaultValueshandleSubmit
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>;- not
.min(1, "...")— user-friendly messages on every validator.nonempty() - Cross-field: /
.refine()at schema root.superRefine() - Shared fields: extract base schema → /
.extend().merge() - Coercion: (trim, parse numbers)
.transform()
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: next to input
errors.<field>.message - Server errors: →
setError("root", { message: "..." })errors.root?.message - A11y: ,
aria-invalid,aria-describedby,role="alert"+<label htmlFor>on every inputid - (default) — auto-focuses first errored field
shouldFocusError: true
- 字段级错误:在输入框旁显示
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 ,
isSubmittingafter success when neededreset() - prop for modal/wizard forms; direct service call for self-contained forms
onSubmit
tsx
const onSubmit = (data) => {
mutate(data, {
onError: () => form.setError("root", { message: "Login failed" }),
});
};- 精简提交处理函数 — 将逻辑委托给服务/突变层
- 在状态下禁用按钮,成功后按需调用
isSubmittingreset() - 模态框/向导表单使用属性;独立表单直接调用服务
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
性能优化
- — never
useWatch({ name: "field" })with no argswatch() - Extract heavy input groups into sub-components (Controller scopes re-renders)
- for expensive derived values
useMemo
- 使用— 绝不调用无参数的
useWatch({ name: "field" })watch() - 将复杂输入组提取为子组件(Controller会限定重渲染范围)
- 对昂贵的派生值使用
useMemo
Anti-Patterns
反模式
| ❌ Don't | ✅ Do |
|---|---|
| |
| |
| Inline schema in component | |
Manual | |
Validation in | Zod schema |
| Multiple forms in one file | One form per file |
| |
Missing | Always provide them |
Missing | Always add it |
No | |
| No submit guard | Disable during |
| ❌ 禁止做法 | ✅ 推荐做法 |
|---|---|
使用 | 使用 |
在自定义输入组件上使用 | 使用 |
| 在组件内定义内联Schema | 将Schema写在 |
手动定义 | 使用 |
在 | 使用Zod Schema进行验证 |
| 一个文件中包含多个表单 | 一个文件对应一个表单 |
调用无参数的 | 使用 |
未提供 | 务必提供默认值 |
未添加 | 务必添加该属性 |
错误提示未添加 | 添加 |
| 未添加提交防护 | 在 |
Dependencies
依赖安装
bash
npm install react-hook-form zod @hookform/resolversbash
npm install react-hook-form zod @hookform/resolversreact-hook-form@^7.50 zod@^3.22 @hookform/resolvers@^3.3
react-hook-form@^7.50 zod@^3.22 @hookform/resolvers@^3.3
undefinedundefinedNew Form Checklist
新表单检查清单
- — Zod schema +
schemas/form-name.schema.tstypez.infer - —
forms/FormName.tsx+useForm+zodResolverdefaultValues - wrappers for all inputs
Controller - ,
noValidateerrors,aria-*guardisSubmitting - Server errors via
setError("root") - Thin submit → service/mutation layer
- — Zod Schema +
schemas/form-name.schema.ts类型导出z.infer - — 包含
forms/FormName.tsx+useForm+zodResolverdefaultValues - 所有输入组件均使用包装
Controller - 包含、无障碍错误属性、
noValidate防护isSubmitting - 通过处理服务器错误
setError("root") - 精简提交逻辑 → 委托给服务/突变层