策略注册表里调 Hook 被 ESLint 封杀——PascalCase 子组件是唯一解
搭了一套字段渲染器的策略注册表,每种字段类型有自己的 handler 对象,暴露一个 renderEditor 函数。前几个类型是纯渲染,没有问题。
轮到一个带异步字典查询和条件级联的字段类型时,renderEditor 里自然地加了 useState、useMemo 和自定义 usePresetDict——ESLint 当场红线:
React Hook "useState" is called in function "renderEditor" which is neither a
React function component nor a custom React Hook function.
第一反应是加 // eslint-disable-next-line,但这条路走不通——不是规则太严格,而是规则在提示真实 bug 风险。
现象
export const presetEnumHandler = {
renderEditor: (props: FieldRendererProps) => {
const [open, setOpen] = useState(false) // ❌ ESLint 报错
const dict = usePresetDict(props.col.presetKey) // ❌ 同上
if (props.col.cascade) {
return renderCascadePath(props) // ❌ 小写 helper 里也有 hook
}
return <Select open={open} onOpenChange={setOpen} options={dict.options} />
}
}把 hook 移入一个小写 helper renderCascadePath 也不行,ESLint 同样拒绝。
根因
react-hooks/rules-of-hooks 只允许在两种函数里调用 hook:
- React 函数组件:函数名以大写字母开头(ESLint 插件内部用
/^[A-Z]/匹配) - 自定义 hook:函数名以
use开头
注册表 handler 里的 renderEditor 是小写绑定的箭头函数,两条都不满足。辅助函数 renderCascadePath 也是小写,同样不被接受。
eslint-disable react-hooks/rules-of-hooks 能让报错消失,但不能修复底层问题:非组件函数里的 hook 无法和 React 的 Fiber 树正确关联,在条件分支、remount、stale closure 场景下行为不可预期。这条规则存在的意义正是提前暴露这类风险,禁用它等于把定时炸弹藏进代码里。
正解
每个需要调用 hook 的渲染分支,提取成独立的 PascalCase 函数组件。handler 的 renderEditor 退化为一行 JSX 工厂,什么都不做,只负责实例化。
// 每个逻辑分支拥有自己的组件 —— ESLint 全部通过
function CascadePath({ col, value, onChange, row }: FieldRendererProps) {
const dict = usePresetDict(col.presetKey!) // ✅ 组件里调 hook
const parentValue = row?.[col.parentFieldKey!]
const filtered = useMemo(
() => dict.options.filter(o => o.parent === parentValue),
[dict.options, parentValue]
)
return <Select options={filtered} value={value} onChange={onChange} />
}
function PlainPath({ col, value, onChange }: FieldRendererProps) {
const dict = usePresetDict(col.presetKey!) // ✅
const [open, setOpen] = useState(false) // ✅
return (
<Select
open={open}
onOpenChange={setOpen}
options={dict.options}
value={value}
onChange={onChange}
/>
)
}
// 分发组件:条件渲染 OK,条件调 hook 不 OK
function PresetEnumEditor(props: FieldRendererProps) {
if (props.col.cascade) return <CascadePath {...props} />
return <PlainPath {...props} />
}
// handler 只剩一行
export const presetEnumHandler: FieldRenderer = {
name: 'preset_enum',
match: (col) => col.type === 'preset_enum',
renderEditor: (props) => <PresetEnumEditor {...props} />, // ✅
}把每个 hook 调用点都移入 PascalCase 函数组件,ESLint 满意,React DevTools 也能正确建立组件树,re-render 和 stale closure 行为完全符合预期。分发组件内部可以做条件渲染(if (x) return <A />),但不能做条件 hook 调用(if (x) const v = useState())——这两者是两个不同的问题。
何时不需要子组件
如果 renderEditor 是纯渲染(无 state、无 effect、无 context、无异步查询),保持行内箭头函数完全没问题:
export const stringHandler: FieldRenderer = {
name: 'string',
match: (col) => col.type === 'string',
renderEditor: ({ value, onChange }) => (
<Input value={String(value ?? '')} onChange={e => onChange(e.target.value)} />
),
}只在「确实需要 hook」时才拆子组件,不要过度设计。
一句话外卖
React 靠函数名首字母大写识别组件——注册表 handler 的
renderEditor永远是小写,hook 只能活在 PascalCase 子组件里,别试图用eslint-disable绕过这道门。