Pressidian
花园入口
笔记
项目
关于
实验室
GitHub
花园入口
笔记
项目
关于
实验室
GitHub

KNOWLEDGE PATHS

笔记库
当前位置
笔记库/前端/项目笔记/代达罗斯/模式

表单最佳实践指南

2 分钟阅读 · Note

目录树 578 篇

            • 表单最佳实践指南
            • 双层级导航结构
            • 以schema为中心的
            • 异步三态切换
            • 用neverthrow进行错误处理
            • Anatomy
            • cn
            • LoadingState&useList的组合
            • procedure中的service位置
            • scrollbar-gutter
            • TailwindCSS
            • TanStack Router 路由模式
            • useDebounce
            • useForm
            • void
          • 项目待做
          • 性能优化
          • UI设计
      • 前端技术栈
    • 笔记目录
    • CLAUDE.md
    • Vue 组件与 Render 函数

关联笔记 6

↗双层级导航结构同一路径↗以schema为中心的同一路径↗异步三态切换同一路径↗用neverthrow进行错误处理同一路径↗Anatomy同一路径↗cn同一路径
  • 表单最佳实践指南

表单最佳实践指南

本文基于项目 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 时 ──→ 副本 ──→ │  │  接收副本,执行业务逻辑      │
└───────────────────────────────────┘  └────────────────────────────┘

三个铁律:

  1. 绝不用 useState / useReducer 管理表单字段 — 一切由 react-hook-form 接管
  2. 表单字段值绝不实时写入 zustand store — 只在 onSubmit 时输出副本
  3. defaultValues 必须是深拷贝 — { ...originalData } 或 structuredClone(data),防止编辑过程污染原始数据

二、两种范式

范式 A:独立可复用表单组件

  • 适用:跨页面复用、纯输入收集器、不带具体业务流程
  • 特征:
    • 组件接收 initialData + onSubmit props(由父组件决定数据与提交逻辑)
    • 内部 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&lt;T&gt;() 创建并持有
    • 组件用 useForm({ formControl: slice.formControl }) 接管
    • submit 整条流程(mutate / navigate / toast / close)在 slice 内完成
    • 组件不接 initialData / onSubmit props
// 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 只能由 &lt;form onSubmit={form.handleSubmit(handleFormSubmit)}&gt; 消费,禁止组件内 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 导入
  • [ ] 外层有 &lt;Form {...form}&gt;
  • [ ] 每个字段走 FormField → FormItem → FormControl → {...field} → FormMessage
  • [ ] defaultValues 是深拷贝副本
  • [ ] 子组件用 FormProvider + useFormContext(),不传 control props
  • [ ] 选定范式 A 或 B,全表单一致,不混用
  • [ ] 范式 B 场景:formControl 在 slice 中创建,handleFormSubmit 在 slice 中实现

八、Bad Case

  • [ ] ❌ 不存在用 useState 管理表单字段的情况
  • [ ] ❌ 不存在表单字段值实时双向绑定 zustand store 的情况
  • [ ] ❌ 不存在手动 value + onChange 管理字段的情况
  • [ ] ❌ 不存在 control / register 通过 props 逐层传递的情况
  • [ ] ❌ (范式 B)不存在组件接 initialData / onSubmit props 的情况
  • [ ] ❌ (范式 B)不存在组件内本地 useForm({ resolver, defaultValues }) 的情况