React Hook Form Orchestration
核心规则
把跨字段的表单编排集中在一个 controller/container 层。表单字段组件只负责渲染字段并调用显式处理函数;它们不拥有分散的表单状态机。
适用场景
- 一个字段变更会重置或设置另一个字段的默认值。
- 一个字段变更必须重新校验另一个字段。
- 被监听的值会触发 API 调用、防抖变更或报价获取。
- 校验依赖派生的最大/最小值、代币精度、余额或报价输出。
- 在某个字段输入后表单冻结或循环。
- 组件同时包含 useWatch/watch、useEffect 以及 setValue 或 trigger。
必需结构
遵循将编排与字段分离的功能模块结构:
- 把编排放在 container/controller 组件中,而不是 components/formItems/*.form.tsx 中。
- 保持 formItems/*.form.tsx 仅负责字段:渲染输入框/选择框/按钮,必要时读取本地字段状态,并调用 props 回调。
- 把选项/常量放在 constants/ 中。
- 把可复用的纯逻辑放在 utils/ 中;不要放异步工具。
- 把不可复用的一次性表单逻辑放在 controller 中,而不是模块级 hooks 中。
避免
不要把这种模式放进表单字段项中:
const value = useWatch({ control, name: 'some_field' });
useEffect(() => { form.setValue('other_field', nextValue, { shouldValidate: true }); form.trigger('third_field'); }, [value, form]);
这会形成一张隐藏的图:
watch -> render -> effect -> setValue/trigger -> formState update -> render
当被监听的结果被用作 effect 依赖时,它尤其脆弱。React Hook Form 文档说明 watch/useWatch 的结果是渲染阶段优化的;当它们必须驱动 effect 时,应使用外部比较。
推荐模式
让依赖转换由事件驱动:
function handleTargetChainChange(chainId: string) { const asset = getDefaultTargetAsset(chainId);
form.setValue('target_chain', chainId, { shouldDirty: true, shouldValidate: true, }); form.setValue('target_asset', asset, { shouldDirty: true, shouldValidate: true, });
if (form.getValues('recipient')) void form.trigger('recipient'); }
然后把该处理函数传入仅负责字段的表单项:
<TargetChainSelect value={targetChainId} onChange={handleTargetChainChange} />
报价/API 副作用
把异步副作用保留在 controller 中,并让它们由 key 驱动:
const quoteRequest = useMemo(() => { if (!canQuote) return null; return { key: [chain, token, amount, recipient].join('|'), params, }; }, [canQuote, chain, token, amount, recipient]);
useEffect(() => { if (!quoteRequest) return; const timer = setTimeout(() => fetchQuote(quoteRequest.params), 500); return () => clearTimeout(timer); }, [quoteRequest, fetchQuote]);
不要把报价获取分散到各个表单项中。不要让报价更新立即触发大范围校验,除非派生校验 key 确实发生了变化。
校验
优先基于表单值和显式的派生 controller 状态进行校验。
如果校验的最大/最小值发生变化,应通过稳定的原始类型 key 重新校验,而不是通过对象标识。
尽可能避免渲染时的 ref 侧通道。如果 resolver 必须读取 ref,应在 controller 中写入它,并让重新校验保持受保护状态。
避免在由表单订阅触发的 effect 中使用 setValue(..., { shouldValidate: true })。
审查清单
在以下情况拒绝或重构:
- components/formItems/*.form.tsx 为跨字段行为导入了 useWatch、watch、useEffect、setValue 或 trigger。
- 一个表单项既读取一个字段又修改另一个字段。
- 依赖数组包含整个 form 对象,而 effect 调用了 setValue 或 trigger。
- 一个防抖 API 调用在表单项内监听许多字段。
- resolver 行为依赖在子组件渲染期间被修改的值。
在以下情况接受:
- 一个 controller 拥有 useForm、被监听值、派生值、依赖字段处理函数、API 副作用以及提交禁用状态。
- 表单项是纯渲染组件。
- 跨字段转换由显式的用户事件处理函数运行。
- effect 由稳定的原始类型字符串/数字作为 key,并防止重复的状态写入。