表单状态越管越乱?把复杂度拆开看,从受控组件到 React Hook Form 的取舍逻辑

表单状态管理的本质问题不是代码量,而是你把三件事混在了一起:数据存储、同步逻辑、校验规则。把这三层拆开,选型逻辑自然就清晰了。

2019 年我在一个贷款审批系统里踩过这个坑——一个包含 47 个字段、7 级联动的企业信息表单,用纯受控组件写了 1200 多行代码,每次修改一个联动逻辑都要在三个文件里跳来跳去。后来用 React Hook Form 重写,代码量降到 400 行以内,但过程中发现并不是所有场景都适合扔掉受控组件。

受控组件的真实成本不在初始代码,而在维护阶段的状态同步

一个常见的误区是认为受控组件只是代码多一点而已。实际上,受控组件的维护成本会随着表单复杂度指数级增长,原因是它把三套逻辑强行耦合在了一起。

看一个典型的受控表单片段:

const [formData, setFormData] = useState({
  companyName: '',
  creditCode: '',
  legalPerson: '',
  registeredCapital: '',
  // ... 43 more fields
});

const [errors, setErrors] = useState({});

const handleChange = (field: string, value: string) => {
  setFormData(prev => {
    const next = { ...prev, [field]: value };
    
    // 联动逻辑 1:当信用代码变化时,自动填充法人信息
    if (field === 'creditCode' && value.length === 18) {
      const legalPerson = fetchLegalPerson(value); // 异步调用
      // 这里就开始混乱了——异步结果如何合并到 next 里?
    }
    
    return next;
  });
  
  // 校验逻辑混在 onChange 里
  if (field === 'creditCode') {
    const error = validateCreditCode(value);
    setErrors(prev => ({ ...prev, creditCode: error }));
  }
};

这里有三个问题层层叠加:

第一层:数据存储。 每次 onChange 都创建新的 formData 对象,47 个字段意味着每次按键触发 47 个属性被浅拷贝。React 18 的自动批处理让渲染次数不再是问题,但对象创建的开销在移动端低配设备上依然可观——我在一台红米 Note 9 上实测,47 字段受控表单的键盘输入延迟达到了 180ms,而用户感知到卡顿的阈值是 100ms。

第二层:同步逻辑。 联动规则散落在多个 onChange 处理器里,A 字段影响 B、B 影响 C、C 又反过来影响 A 时,你需要小心翼翼地处理循环依赖。我见过最糟糕的情况是三个开发者在不同时期添加了互相矛盾的联动规则,表单在特定输入组合下进入无限 setState 循环。

第三层:校验规则。 校验逻辑和 UI 逻辑耦合在一起,你想复用校验函数到另一个表单时,发现它内部引用了 setErrors 和组件状态。更麻烦的是,当校验依赖多个字段值时(比如"注册资本不能超过营业额的 30%"),你需要在每个相关字段的 onChange 里重复这个校验。

这三层耦合导致了一个具体的后果:修改一个联动规则时,你必须同时改动数据更新、错误清除、UI 禁启用三个地方。这在 10 个字段以内不明显,超过 20 个字段后就会变成维护噩梦。

非受控组件的性能优势被夸大了,真正的好处是关注点分离

很多人推荐 React Hook Form 的理由是"性能更好,减少了重渲染"。这个说法在 2020 年以前成立,但在 React 18 的并发特性和自动批处理之后,纯受控组件的重渲染问题已经大幅缓解。

实际上,React Hook Form 的核心价值不是性能数字,而是它强制你分离了三个关注点。

用 React Hook Form 重写上面的表单:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 第一层:校验规则独立定义
const schema = z.object({
  companyName: z.string().min(2, '企业名称至少2个字符'),
  creditCode: z.string().length(18, '统一社会信用代码为18位'),
  legalPerson: z.string().optional(),
  registeredCapital: z.number().positive('注册资本必须大于0'),
});

type FormData = z.infer<typeof schema>;

// 第二层:数据存储交给 useForm 内部管理
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>({
  resolver: zodResolver(schema),
  defaultValues: {
    companyName: '',
    creditCode: '',
  }
});

// 第三层:同步逻辑通过 watch 集中处理
const creditCode = watch('creditCode');
useEffect(() => {
  if (creditCode?.length === 18) {
    fetchLegalPerson(creditCode).then(name => {
      setValue('legalPerson', name, { shouldValidate: true });
    });
  }
}, [creditCode]);

注意这里的变化:

  • 校验规则(zod schema)完全脱离 React 组件,可以在任何地方复用和测试;
  • 数据存储由 useForm 内部的 ref 管理,你不需要关心对象创建和渲染优化;
  • 同步逻辑通过 watch + useEffect 集中在一个地方,所有联动规则一目了然。

但这不是免费的。watch 默认会触发整个表单的重新渲染,因为它在内部使用了 useState 来触发订阅更新。React Hook Form 的文档里也明确说了:如果你想精确订阅某个字段且不触发重渲染,应该用 useWatch 或者在 register 里使用自定义 onChange。v7.45 版本以后,useWatch 在内部使用了 useSyncExternalStore,能够在并发模式下保持一致性,但它的 API 设计依然不够直观。

选择受控还是非受控,取决于你的表单属于哪种复杂度类型

我在实际项目里把表单分成三种类型,每种对应不同的选型策略:

类型一:线性表单(字段数 < 15,联动 < 3 条,无异步校验)

这类表单用纯受控组件完全没问题。代码量可控,逻辑清晰,没有引入第三方库的必要。一个登录表单、简单的设置页面都属于这类。

类型二:联动密集型表单(字段数 15-40,联动规则 > 5 条,有跨字段校验)

这是 React Hook Form 最擅长的领域。联动规则通过 watch 集中管理,跨字段校验通过 zod 的 refinesuperRefine 处理。2023 年我在一个保险理赔系统里用这个方案处理了包含 32 个字段、11 条联动规则的表单,联动逻辑从原来的分散在 8 个组件里收敛到了一个 useFormLogic 自定义 hook 中。

但要注意一个坑:watch 订阅的是整个表单,如果你在 useEffect 里依赖了 watch('a')watch('b'),但同时又在 effect 里修改这两个字段,很容易触发无限循环。解决方案是用 getValues() 代替 watch 来读取不需要订阅的字段值:

const a = watch('a'); // 需要订阅,a 变化时触发 effect

useEffect(() => {
  const b = getValues('b'); // 不需要订阅,只读取当前值
  if (a === 'certainValue' && b !== 'expectedValue') {
    setValue('b', 'expectedValue');
  }
}, [a]); // 只依赖 a,避免循环

类型三:超大型表单(字段数 > 40,多步骤,有复杂异步依赖)

这类表单即使使用 React Hook Form 也会遇到性能瓶颈。watch 所有字段、formState 的实时更新在高频输入场景下依然会产生可感知的延迟。

我的做法是在 React Hook Form 之上再加一层:将表单拆分成多个子表单,每个子表单独立使用 useForm,通过 FormProvider 共享上下文。同时将校验从 mode: 'onChange' 改为 mode: 'onBlur'mode: 'onSubmit',减少实时校验的计算量。

// 父表单只负责步骤控制和最终提交
const ParentForm = () => {
  const [step, setStep] = useState(1);
  const methods = useForm({ mode: 'onSubmit' }); // 只在提交时校验
  
  return (
    <FormProvider {...methods}>
      {step === 1 && <CompanyInfoSection />}
      {step === 2 && <FinancialSection />}
      {step === 3 && <LegalSection />}
    </FormProvider>
  );
};

// 子表单使用独立的 useForm,但通过 FormProvider 共享数据
const CompanyInfoSection = () => {
  const { register, formState } = useFormContext();
  // 字段只注册到父表单的命名空间下
  return <input {...register('companyInfo.name')} />;
};

这个方案在 v7.47.0 版本下测试通过,但需要注意 FormProvider 传递的是整个 form context,子表单的任何字段变化都会触发所有使用 useFormContext 的组件重渲染。如果你的子表单非常独立,更极端的做法是完全不用 FormProvider,每个子表单维护自己的 useForm,在最后提交时手动合并数据。

动态字段列表的受控与非受控实现,差距在代码量上

动态字段列表(Field Array)是表单复杂度的一个分水岭。用受控组件实现一个可增删的列表,你需要手动管理索引、处理删除后的状态同步、保证 key 的稳定性:

const [items, setItems] = useState([{ id: '1', name: '', amount: '' }]);

const addItem = () => {
  setItems(prev => [...prev, { id: uuid(), name: '', amount: '' }]);
};

const removeItem = (id: string) => {
  setItems(prev => prev.filter(item => item.id !== id));
};

const updateItem = (id: string, field: string, value: string) => {
  setItems(prev => prev.map(item => 
    item.id === id ? { ...item, [field]: value } : item
  ));
};

这还只是最基础的增删改。如果加上排序、复制、批量删除,代码量很快膨胀到 200 行以上。

React Hook Form 的 useFieldArray 把这些操作封装成了几个方法:

const { fields, append, remove, swap, insert } = useFieldArray({
  control,
  name: 'items'
});

// append 自动生成稳定 key,remove 自动处理索引重排
append({ name: '', amount: '' });
remove(2); // 删除索引 2 的元素,后续元素自动前移

代码量减少 60% 以上。但这里有一个 v7 版本的已知问题:fields 数组的 id 是内部生成的随机字符串,如果你需要在删除时做动画或过渡效果,这个 id 会在每次 append 时重新生成所有项的 id(v7.0 到 v7.49 版本都存在这个问题)。解决方案是使用 keyName 参数指定使用你自己的唯一标识:

const { fields } = useFieldArray({
  control,
  name: 'items',
  keyName: '_customId' // 使用你自己的 id 字段作为 key
});

常见问题

React Hook Form 和 Formik 怎么选?

如果你的项目已经在用 Formik 且没有性能问题,不需要迁移。如果新项目选型,React Hook Form 在 v7 之后对 TypeScript 的支持明显更好——useForm 的泛型能自动推导出 registersetValuewatch 的参数类型,而 Formik 的 valueshandleChange 里依然是 any 类型(截至 Formik 2.4.5)。另外 React Hook Form 的包体积只有 9.1KB(v7.49.0 minified + gzipped),Formik 是 12.6KB,差距不大但 React Hook Form 零依赖。

什么时候必须用受控组件?

当表单字段的值需要实时同步到组件外部的状态时,比如一个搜索框的输入要同时更新 URL 参数和 Redux store。React Hook Form 虽然可以通过 watch + useEffect 实现同步,但三层嵌套的 effect 依赖很容易出错。另一个场景是需要对输入做实时格式化(如信用卡号每 4 位加空格),React Hook Form 的 setValue 配合 shouldDirty 选项可以做到,但代码不如受控组件直接操作 state 来得直观。

多步骤表单用 React Hook Form 还是每个步骤独立受控?

如果步骤之间有字段依赖(比如步骤 2 的选项取决于步骤 1 的选择),用单一的 useForm 配合 FormProvider 更方便,数据天然共享。如果步骤完全独立,每个步骤各自维护状态,最后统一提交时合并,这样单个步骤的复杂度更低。我倾向于后者,因为步骤独立时修改某个步骤不会影响其他步骤的表单状态,调试时更容易定位问题。