mcpField
R
react-hook-form

react-hook-form

github.com/supabase开发114.6k73 次安装今天更新
代码仓库

关于 react-hook-form

面向现有代码库中 React Hook Form 表单开发与维护的技能,重点讲解如何限定订阅范围,并保持受控值、默认值、校验和重置行为一致。

主要功能

  1. 01使用 useWatch 和 useFormState 限定读取范围
  2. 02完整默认值与服务端 values
  3. 03受控输入与 null 值处理
  4. 04安全解析数字输入
  5. 05脏状态与重置指导

适用场景

  1. 01添加响应式字段且避免整棵表单重渲染
  2. 02从 API 填充表单并保留用户编辑
  3. 03安全处理可空文本和数字字段
  4. 04更新表单并避免 lint 标记的 watch 写法

安装

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

$npx skills add https://github.com/supabase/supabase --skill react-hook-form

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

详细介绍

React Hook Form

如何编写能够随着表单增长而保持正确的代码。现有代码库不适合作为 安全参考:从通过 props 层层传递的表单对象调用 form.watch()、仅订阅的 监听、未加保护的 valueAsNumber,以及 ?? undefined 受控值,在旧代码中都很 常见,而且都是错误做法。请遵循本规范,而不是参考相邻文件。

策略——修改哪里,就修好哪里。 新代码必须遵循这些规则。修改现有表单代码时, 将你正在编辑的特定字段/钩子/组件升级为符合规范的写法(例如,你修改的组件中 若调用了 form.watch,就改用 useWatch)。未触及的代码保持原样,但要告知用户 你注意到却没有修复的反模式。切勿新增违规项:Studio CI 中的 react-hook-form/no-use-watch 告警计数采用递增限制,告警数一旦增加,构建就会失败。

心智模型:订阅决定哪些组件会重新渲染

RHF 本质上是非受控的。值存储在引用中;除非订阅触发,否则不会重新渲染。每种读取 API 都是在决定如何订阅:

API是否订阅重新渲染范围用途
useWatch({ control, name })是仅调用该 API 的组件响应式读取值,任何位置均可
useFormState({ control })是仅调用该 API 的组件在表单所有者之外读取 isDirty/errors 等状态
formState(解构后)是useForm 所有者仅在所有者组件中读取表单状态
form.watch(name)是整个表单树避免使用——会被 lint 标记,见下文
getValues()否从不仅用于事件处理函数和 onSubmit
subscribe()回调无在渲染之外执行副作用

以下两点解释了我们已上线的大多数 bug:

  1. form.watch() 和 form.formState 会将订阅提升到 useForm 所有者,无论是哪个组件调用它们。 子组件通过 props 读取 form.watch('x'),目前之所以能正常工作,只是因为每次值更改时整个树都会重新渲染—— 只要有人在所有者和子组件之间添加 React.memo,它就会悄然变成过时值;在此之前, 每次按键都会让所有兄弟组件重新渲染。无参的 form.watch() 会设置 watchAll, 并在表单的整个生命周期内,每次字段更改时都重新渲染整棵树。
  2. formState 是一个 Proxy——读取属性时才会启用订阅。应解构它 (const { isDirty } = form.formState),切勿传递该对象,也不要有条件地读取它 (a && formState.isValid 可能永远不会触发订阅)。 这一点由 react-hook-form/destructuring-formstate 强制执行(错误)。
按位置读取值
  • 在拥有 useForm 的组件中: 解构 formState;即使在这里,也优先使用 useWatch 而非 form.watch(no-use-watch 规则会标记所有 watch,而且如果之后将 JSX 提取出去,useWatch 可以限定重新渲染的范围)。
  • 在任何子组件或自定义钩子中: 接收 control(而不是整个 form),并使用 useWatch({ control, name }) / useFormState({ control })。 在 <Form {...form}>(它确实是 FormProvider)内部,也可以使用 useFormContext() + useWatch({ name }),完全避免逐层传递属性。
  • 使用返回值。 不要为了订阅副作用而调用 watch,之后又通过 getValues() 读取——watch 列表和读取列表会 逐渐不一致(这种情况已经发生过;字段会悄无声息地失去响应性)。你渲染的值必须就是你订阅的值。
  • 每个值在每次渲染中只用一种读取方式。 在一行使用 useWatch('x'),几行后又使用 getValues('x'),会导致两者在同一次渲染中出现不一致。
  • 明确指定要监听的名称。 不带 name 的 useWatch({ control }) 会在每个字段每次按键时都重新渲染。请订阅你实际使用的特定名称。
  • watch(callback) 已弃用——对于不触发渲染的监听器,请使用 subscribe(),并始终从 useEffect 返回其清理函数。
// ❌ common in the codebase — all three subscriptions hoist to the form owner
function Fields({ form }: { form: UseFormReturn<FormValues> }) {
  form.watch(['storageType', 'totalSize'])        // return value discarded
  const { errors } = form.formState               // prop-form formState
  const size = form.getValues('totalSize')        // non-reactive read in render
  ...
}

// ✅ child subscribes for itself and consumes what it watches
function Fields({ control }: { control: Control<FormValues> }) {
  const [storageType, totalSize] = useWatch({ control, name: ['storageType', 'totalSize'] })
  const { errors } = useFormState({ control })
  ...
}

标准表单

zod schema → z.infer 类型 → 使用 zodResolver 和完整 defaultValues 的 useForm → <Form {...form}> → 每个字段使用 FormField 渲染属性 → FormItemLayout → FormControl → 来自 ui 的基础组件。布局/容器选择 (Card 与 Sheet、layout= 变体)由 studio-ui-patterns 技能 以及 apps/design-system/registry/default/example/ 中的演示示例 (form-patterns-pagelayout.tsx、form-patterns-sidepanel.tsx)介绍——设计结构前请先查看。

// Module level — static references, not recreated on every render
const FORM_ID = 'pool-config-form'

const FormSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  maxConnections: z
    .union([z.literal(''), z.coerce.number().gte(1, 'Must be at least 1')])
    .refine((v) => v !== '', 'Max connections is required'),
})
type FormValues = z.infer<typeof FormSchema>

const defaultValues: FormValues = { name: '', maxConnections: '' }

// Inside the component
const form = useForm<FormValues>({
  resolver: zodResolver(FormSchema),
  defaultValues,
})

<Form {...form}>
  <form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
    <FormField
      control={form.control}
      name="name"
      render={({ field }) => (
        <FormItemLayout layout="horizontal" label="Name">
          <FormControl>
            <Input {...field} />
          </FormControl>
        </FormItemLayout>
      )}
    />
  </form>
</Form>

在模块级别、组件外部定义 schema、type、静态 defaultValues 和表单的 id。每次渲染都重新创建这些内容是徒劳的工作, 还会导致引用不稳定——RHF 只会在首次渲染时读取 defaultValues,但其他任何与这些对象进行比较的代码每次都会看到一个新的对象引用。 当它们确实依赖运行时数据时,请使用 useMemo 构建 schema,并通过 values 选项传入由服务端驱动的默认值(见下一节),而不要将其提升至模块级别。

位于 <form> 外部的提交按钮(例如 Sheet/对话框页脚中的按钮)也要通过按钮上的 form={FORM_ID} 使用同一个模块级 FORM_ID。模块级 id 仅适用于单例表单——如果组件可能同时挂载多次,重复的 id 会导致外部按钮提交第一个匹配的表单,因此请使用 useId() 创建每个实例专属的 id,并在 <form> 和其按钮之间共享该 id。

defaultValues、服务器数据与重置

  • 提供完整的 defaultValues 对象——包含每个字段,且不得使用 undefined。 isDirty、dirtyFields 和 Cancel 重置都会以它为基准进行比较;缺少默认值或默认值为 undefined 会导致这三者都无法正常工作,而且 undefined 也会让 React 将输入框视为非受控(见下文)。
  • 表单数据来自 API?使用 values 选项,不要手动编写 effect。 values 会在查询完成后自动更新,并为你重置表单;如果查询可能尚未加载完成,从查询结果计算 defaultValues 会使表单在挂载时固定使用缓存中的现有数据。若后台重新获取数据时不能覆盖用户正在进行的编辑,请添加 resetOptions: { keepDirtyValues: true }。keepDirtyValues 会保留 formState.dirtyFields 中的值;无论是否订阅状态,输入编辑和 setValue(…, { shouldDirty: true }) 都会填充它,但 useFieldArray 操作(append/remove/move)只有在订阅了 dirtyFields 或 isDirty 时才会将字段标记为脏——因此,将 keepDirtyValues 与字段数组结合使用的表单,必须在所属组件中读取其中一项,否则下次重新获取数据时数组编辑会丢失。(示例: components/interfaces/Settings/Database/ConnectionLogging.tsx、 components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx。)
  • 成功执行变更后,在 onSuccess 中重新设定表单基准,让已保存的状态成为新基准(isDirty 会恢复为 false,Cancel 会还原为已保存的值)。优先使用服务器实际持久化的数据:如果表单使用 values,且变更会使查询失效,重新获取的数据会自动处理;如果变更返回更新后的资源,则使用 reset(response)。 reset(submittedValues) 适用于服务器原样存储所提交数据的 API,是一种备用方案——如果服务器会规范化或补充数据,就会让表单以从未保存过的数据作为基准。直接调用 reset() 会恢复到_之前_的默认值——保存后这样做是错误的。
  • Cancel 按钮调用 form.reset()。只有值能通过有定义的受控值往返还原的字段才会在视觉上恢复——这就是下文的 null 规则很重要的原因。

受控输入:绝不让 value 变成 undefined

React 会根据每次渲染时 value 是否有定义,来判断输入框是受控还是非受控。如果字段值可能是 undefined(或在重置时变成 undefined),就会切换模式:控制台出现警告,而且更糟的是,React 会放弃 DOM 中的值,导致 reset() 不再清除可见文本。value={field.value ?? undefined} 是个错误,不是修复方法。

  • 文本字段:默认值设为 '',绝不要设为 null/undefined。
  • 在表单边界处规范化 API 返回的 null(构建默认值时使用 growthPercent ?? ''),并在提交时转换回去('' → null)。不要用看似实际值的 placeholder 来掩盖 null 默认值:用户看到的是“50”,表单中保存的却是 null,于是所有后续比较 (defaultValues.growthPercent !== watched → null !== 50)都会报告一个永久的虚假变更,而 Cancel 则会悄悄地无法重置该字段。
  • 选择框/单选按钮:默认值设为 '' 或实际选项值;复选框/开关设为 false。

数字输入

推荐的模式将 '' 保留为“空”哨兵值,让输入保持受控,并让 zod 在验证时进行强制转换(参见上面的 maxConnections): z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…') 搭配普通的 <Input {...field} type="number" />。

如果改为通过 e.target.valueAsNumber(或 valueAsNumber: true)连接 onChange,空输入或只输入了一部分的输入会产生 NaN,它会进入表单状态,并传播到之后的每项计算、价格预览和 value 属性中。请使用字段架构声明的同一个空哨兵值进行保护——对于上面的 '' 联合架构: field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber)。 绝不要让 NaN 进入表单状态。

可空的 API 字段(null =“未设置”,例如使用平台默认值)不会改变表单内的哨兵值——表单内仍使用 '',并在边界处转换:

// inbound: null → '' when building defaults/values
values: { growthPercent: data.growth_percent ?? '' },
// schema: '' stays the in-form sentinel, zod coerces real input
growthPercent: z.union([z.literal(''), z.coerce.number().gte(10).lte(100)]),
// outbound: '' → null in onSubmit
mutate({ growth_percent: values.growthPercent === '' ? null : values.growthPercent })

如果 null 确实进入了表单状态(某些现有表单会这样),请避免它进入输入框和强制转换流程:通过 value={field.value ?? ''} 渲染,并且不要将该值传给 z.coerce.number()——Number(null) 的结果是 0,因此传入可空字段的强制转换联合架构会悄悄地把空值验证为 0。无论哪种情况,每个字段都只使用一个哨兵值,并在默认值、架构、onChange、渲染和提交映射中保持一致。

脏状态和变更检测

  • 根据 isDirty 决定是否启用保存;仅在表单有更改时显示取消按钮。在表单所属组件中,从 form.formState 解构;其他位置使用 useFormState({ control })。
  • 当表单位于 Sheet 或 Dialog 中时,还要处理脏状态下的关闭:使用 useConfirmOnClose + DiscardChangesConfirmationDialog。取消、Escape 和背景点击都应经过保护;提交成功后调用原始的 onClose,避免保存后弹出确认提示。详情参见: apps/design-system/content/docs/ui-patterns/modality.mdx(脏表单关闭)和 studio-ui-patterns 技能中的 Sheets 部分。
  • 如需显示更改了_哪些_字段(用于审核/摘要对话框),请从同一个订阅中读取 dirtyFields,而不是手动比较 defaultValues.x !== watchedX。RHF 已能正确进行比较;手动实现的版本会因 null 与占位值不匹配而出错,并且必须手动与监视列表保持同步。
  • 用户输入之外的 setValue 需要显式设置标记: setValue('x', v, { shouldDirty: true, shouldValidate: true })——否则更改不会反映到 isDirty 中,也不会触发验证。

禁用和条件控制

如果某个字段不可编辑(套餐等级、权限、冷却时间),应禁用字段本身——在可编辑输入框旁显示提示并不能限制操作。提示和控件应使用相同的条件。权限检查使用 useAsyncCheckPermissions;需要说明原因的禁用按钮使用 ButtonTooltip。

注意:register/useController 的 disabled: true 会从提交数据中移除字段值。对于值必须保留的“可见但锁定”字段,请使用输入框自身的 disabled/readOnly 属性(如 FormField + 原始控件属性所示),而不要使用 RHF 级别的禁用;也可以使用表单级的 disabled 选项,在异步操作期间冻结整个表单。

提交和变更操作

onSubmit 接收的是经过验证且类型安全的数据——直接使用它;不要通过 getValues() 重新读取。变更操作遵循 Studio 约定:onSuccess → toast.success

  • reset(values)(或使用 values: 时使查询失效),onError → toast.error;将变更操作的 isPending 传给按钮的 loading 属性。默认验证 mode: 'onSubmit' 适用于大多数表单——如需选择其他模式,请有意为之,不要照搬。

生效中的 Lint 规则(Studio)

规则级别含义
react-hook-form/destructuring-formstate错误解构 formState,不要持有该对象
react-hook-form/no-access-control错误不要访问 control 内部
react-hook-form/no-nested-object-setvalue错误使用 setValue('a.b', v),而非 setValue('a', {b:v})
react-hook-form/no-use-watch警告(逐步收紧)使用 useWatch,不要使用 watch

评论

支持 Markdown