表单最佳实践指南
本文基于项目 Skill form-best-practice 提炼,覆盖 TypeScript + React + shadcn/ui + react-hook-form + Zod 架构下的表单编写规范。
一、核心思想:表单是独立的"沙盒"
┌─ 表单状态树(react-hook-form)────┐ ┌─ 全局状态树(zustand)────┐
│ field.value / errors / dirty │ │ store.user / store.ui │
│ isSubmitting / isValid │ │ store.crates / ... │
│ │ │ │
│ ❌ 禁止实时双向绑定 ──────────────│──│ 不接收表单字段值 │
│ │ │ │
│ ✅ 仅在 onSubmit 时 ──→ 副本 ──→ │ │ 接收副本,执行业务逻辑 │
└───────────────────────────────────┘ └────────────────────────────┘
三个铁律:
- 绝不用
useState/useReducer管理表单字段 — 一切由 react-hook-form 接管 - 表单字段值绝不实时写入 zustand store — 只在
onSubmit时输出副本 defaultValues必须是深拷贝 —{ ...originalData }或structuredClone(data),防止编辑过程污染原始数据
二、两种范式
范式 A:独立可复用表单组件
- 适用:跨页面复用、纯输入收集器、不带具体业务流程
- 特征:
- 组件接收
initialData+onSubmitprops(由父组件决定数据与提交逻辑) - 内部
useForm({ resolver, defaultValues: initialData }) - 不依赖某个具体页面 store
- 组件接收
interface MyFormProps {
initialData?: Partial<FormData>;
onSubmit: (data: FormData) => void;
}
export const MyForm = ({ initialData, onSubmit }: MyFormProps) => {
const form = useForm<FormData>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: initialData?.name ?? "",
email: initialData?.email ?? "",
},
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* 字段... */}
</form>
</Form>
);
};
范式 B:页面绑定表单(formControl 在 slice 中)
- 适用:dialog 内表单、单页面表单、表单 + 业务流程紧耦合
- 特征:
formControl由所属页面 slice 通过createFormControl<T>()创建并持有- 组件用
useForm({ formControl: slice.formControl })接管 - submit 整条流程(mutate / navigate / toast / close)在 slice 内完成
- 组件不接
initialData/onSubmitprops
// slice 中
import { createFormControl } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const agentFormControl = createFormControl<AgentFormValues>({
defaultValues: { name: "", description: "" },
resolver: zodResolver(agentFormSchema),
});
return {
agentFormControl,
handleFormSubmit: async (values: AgentFormValues) => {
await dataProvider.create({ resource, variables: values });
agentFormControl.reset();
set({ showForm: false });
},
};
// 组件中
const formControl = useStore(store, (s) => s.agentFormControl);
const handleFormSubmit = useStore(store, (s) => s.handleFormSubmit);
const form = useForm<AgentFormValues>({ formControl: formControl.formControl });
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
{/* 字段... */}
</form>
</Form>
);
> 范式 B 铁律:handleFormSubmit 只能由 <form onSubmit={form.handleSubmit(handleFormSubmit)}> 消费,禁止组件内 JS 主动调用。
三、字段标准结构(Anatomy)
每个字段必须经过 5 层嵌套:
FormField ← 桥接 react-hook-form 的 field 对象
└─ FormItem ← 提供无障碍 label/error 关联
├─ FormLabel ← <Label> 语义标签
├─ FormControl ← 注入 accessibility props
│ └─ <Input {...field} /> ← field 展开接管 value/onChange/onBlur
└─ FormMessage ← 自动渲染校验错误文本
// ✅ 正确写法
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
// ❌ 错误写法 —— 手动 value + onChange
<Input
value={form.watch("email")}
onChange={(e) => form.setValue("email", e.target.value)}
/>
四、Zod Schema 集成
// ✅ 从 zod/v4 导入(项目使用 Zod v4)
import { z } from "zod/v4";
export const MyFormSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
age: z.number().int().positive().optional(),
});
// 类型从 Schema 派生,不手写
export type MyFormData = z.infer<typeof MyFormSchema>;
五、FormProvider 跨组件共享
禁止通过 props 传递 control 或 register:
// ✅ 父组件
const form = useForm<FormData>({ resolver: zodResolver(Schema) });
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<TitleField />
<TagsField />
</form>
</FormProvider>
);
// ✅ 子组件
function TagsField() {
const { control } = useFormContext<FormData>();
return <FormField control={control} name="tags" render={...} />;
}
// ❌ 反模式
function TagsField({ control }: { control: Control<FormData> }) { ... }
六、提交时的数据流
// ✅ 单向输出:表单 → 副本 → 业务层
const handleSubmit = (data: FormData) => {
onSubmit({ ...data }); // 传副本,不传内部引用
};
// ❌ 反模式:onChange 中实时写 store
<Input
{...field}
onChange={(e) => {
field.onChange(e);
store.setName(e.target.value); // ❌ 破坏沙盒
}}
/>
七、检查清单
- [ ] 没有
useState管理字段 - [ ] 没有
value+onChange手动绑定 - [ ]
useForm配了resolver: zodResolver(Schema) - [ ]
z从zod/v4导入 - [ ] 外层有
<Form {...form}> - [ ] 每个字段走
FormField → FormItem → FormControl → {...field} → FormMessage - [ ]
defaultValues是深拷贝副本 - [ ] 子组件用
FormProvider+useFormContext(),不传controlprops - [ ] 选定范式 A 或 B,全表单一致,不混用
- [ ] 范式 B 场景:
formControl在 slice 中创建,handleFormSubmit在 slice 中实现
八、Bad Case
- [ ] ❌ 不存在用
useState管理表单字段的情况 - [ ] ❌ 不存在表单字段值实时双向绑定 zustand store 的情况
- [ ] ❌ 不存在手动
value+onChange管理字段的情况 - [ ] ❌ 不存在
control/register通过 props 逐层传递的情况 - [ ] ❌ (范式 B)不存在组件接
initialData/onSubmitprops 的情况 - [ ] ❌ (范式 B)不存在组件内本地
useForm({ resolver, defaultValues })的情况