React 复杂表单:我为什么不再一路 useState 写到底

我以前写 React 表单,很喜欢从 useState 开始。

一个字段一个状态,字段少的时候非常顺手:

1const [name, setName] = useState("");
2const [email, setEmail] = useState("");
3const [loading, setLoading] = useState(false);

后来做配置平台和审批类页面多了,我对表单的看法变了。业务规则会一层一层叠上来,这才是复杂表单真正麻烦的地方,怎么拿到输入值反而是最简单的一步。

具体到我手上的一个商品配置表单。第一版只有基础信息、价格和几个开关。后面陆续加了规格组合、渠道差异价、草稿保存、导入校验、异步查重、分步骤提交。每次需求都不大,但半年之后,表单已经变成一个没人愿意碰的组件。

具体到代码上,最早那版长这样:组件顶部排着十几个 useState,往下是一堆 useEffect 做联动——价格变了要重算含税价,规格变了要清空渠道价,开关打开要展示一块新区域。每个 useEffect 的依赖数组里都挤着五六个变量,改一个状态经常触发一连串我自己都没完全预料到的更新。最离谱的一次,我在 onChangesetPrice,价格的 useEffect 又去 setChannels,渠道的 useEffect 再回头读 price,结果某些输入下渲染了三四遍才稳定下来,输入框还偶尔吞字。那会儿我才意识到,问题不在某一行代码,而在整个状态是散的,没有一个地方能说清"现在表单处于什么状态"。

问题不是 React 不行,也不是 useState 不行,而是我一开始没有给表单状态分层。

最先坏掉的是状态结构

复杂表单里至少有四类状态:

  • 表单值:用户最终要提交的数据。
  • 字段状态:是否 touched、是否 dirty、是否正在异步校验。
  • 错误状态:字段错误、整表错误、服务端错误。
  • 流程状态:当前在编辑、校验、上传、提交、成功还是失败。

如果这些都塞在一个对象里,代码很快会变成这样:

1setFormState({
2  ...formState,
3  name: value,
4  nameError: "",
5  isSubmitting: false,
6  showConfirm: false,
7});

这类代码最大的问题不是丑,而是它把几个不同层次的东西绑在了一起。改一个字段时顺手改流程状态,提交失败时顺手改表单值,最后谁也说不清一次更新到底代表什么业务动作。

现在我会先拆成几个清晰的区域:

1const initialState = {
2  values: {
3    name: "",
4    skuItems: [],
5    channels: [],
6  },
7  touched: {},
8  errors: {},
9  asyncStatus: {},
10  submitStatus: "idle",
11};

这个结构不一定适合所有项目,但思路很固定:值归值,错误归错误,流程归流程。

reducer 的价值不是“高级”,是能追溯

当表单开始出现多个字段联动时,我更愿意用 useReducer,因为 action 能把“发生了什么”写出来。

1function reducer(state, action) {
2  switch (action.type) {
3    case "field_changed":
4      return {
5        ...state,
6        values: {
7          ...state.values,
8          [action.name]: action.value,
9        },
10        errors: {
11          ...state.errors,
12          [action.name]: undefined,
13        },
14      };
15
16    case "field_validated":
17      return {
18        ...state,
19        asyncStatus: {
20          ...state.asyncStatus,
21          [action.name]: "success",
22        },
23        errors: {
24          ...state.errors,
25          [action.name]: action.error,
26        },
27      };
28
29    case "submit_failed":
30      return {
31        ...state,
32        submitStatus: "error",
33        errors: action.errors,
34      };
35
36    default:
37      return state;
38  }
39}

我会尽量避免万能的 set_state。因为万能 action 看起来省事,实际等于把复杂度推回调用处。表单最需要的不是少写几个 action,而是让每次状态变化都能沿着 action 链路查回去。

这对排查问题特别有帮助。比如用户说“我点保存以后价格被清空了”,如果 action 是业务语义,你能沿着 field_changedprice_recalculatedsubmit_failed 这种链路看;如果全是 set_state,只能翻半天组件代码。

校验规则不要散在事件里

我踩过的另一个坑,是把校验写在触发点附近。

onBlur 里写一份,onChange 里写一份,onSubmit 里再写一份。第一次写很快,后面规则一改,三个地方不一致。

现在我会先把规则抽出来,再决定不同阶段跑哪些规则。

1function validate(values) {
2  const errors = {};
3
4  if (!values.name.trim()) {
5    errors.name = "商品名称不能为空";
6  }
7
8  if (values.skuItems.length === 0) {
9    errors.skuItems = "至少配置一个规格";
10  }
11
12  if (values.channels.some((item) => item.price <= 0)) {
13    errors.channels = "渠道价格必须大于 0";
14  }
15
16  return errors;
17}

字段级校验、整表校验、服务端校验要分开,但规则来源尽量统一。否则后面会出现很怪的问题:页面上显示通过了,提交时又被挡住;或者提交成功了,字段旁边还挂着旧错误。

异步校验最容易让页面“像坏了一样”

用户名、编码、邀请码、配置名称,这些字段经常要异步查重。

我以前只关心请求成功后怎么展示,但竞态其实更危险:

  1. 用户输入 A,发起查重。
  2. 用户立刻改成 AB,又发起查重。
  3. AB 先返回通过,A 后返回重复。
  4. 页面显示当前值重复,但其实重复的是旧值。

这种问题用户说不清,只会觉得页面不可信。

我的处理方式通常是取消旧请求,或者至少只接收最后一次结果。

1function createAsyncValidator() {
2  let controller;
3
4  return async function validateName(name) {
5    controller?.abort();
6    controller = new AbortController();
7
8    const response = await fetch(`/api/check-name?name=${encodeURIComponent(name)}`, {
9      signal: controller.signal,
10    });
11
12    const result = await response.json();
13    return result.available ? undefined : "名称已被占用";
14  };
15}

如果业务环境里不方便取消请求,我会加版本号:

1let latestRequest = 0;
2
3async function validateCode(code) {
4  const requestId = ++latestRequest;
5  const result = await checkCode(code);
6
7  if (requestId !== latestRequest) {
8    return;
9  }
10
11  dispatch({
12    type: "field_validated",
13    name: "code",
14    error: result.valid ? undefined : "编码不可用",
15  });
16}

关键不是用哪种写法,而是要承认“返回顺序不等于输入顺序”。

提交流程不能只有 loading

复杂表单最常见的坏味道,是只有一个 loading

但真实提交流程可能分成好几步:

  • 本地校验
  • 上传附件
  • 提交主数据
  • 提交子配置
  • 刷新缓存
  • 跳转结果页

如果全部叫 loading,用户不知道卡在哪里,开发也不好定位。

我现在更倾向于用阶段状态:

1const SubmitStatus = {
2  Idle: "idle",
3  Validating: "validating",
4  Uploading: "uploading",
5  Submitting: "submitting",
6  Success: "success",
7  Error: "error",
8};

提交函数也要像流程,而不是一个巨大的 try/catch

1async function handleSubmit() {
2  dispatch({ type: "submit_status_changed", status: "validating" });
3
4  const localErrors = validate(state.values);
5  if (Object.keys(localErrors).length > 0) {
6    dispatch({ type: "submit_failed", errors: localErrors });
7    return;
8  }
9
10  try {
11    dispatch({ type: "submit_status_changed", status: "uploading" });
12    const attachments = await uploadFiles(state.values.files);
13
14    dispatch({ type: "submit_status_changed", status: "submitting" });
15    await saveConfig({ ...state.values, attachments });
16
17    dispatch({ type: "submit_status_changed", status: "success" });
18  } catch (error) {
19    dispatch({
20      type: "submit_failed",
21      errors: normalizeApiError(error),
22    });
23  }
24}

这类代码看起来比一个 loading 麻烦,但线上排查时很值钱。你能知道失败发生在上传阶段还是主表提交阶段,用户反馈也更容易对应到具体链路。

表单库解决不了建模问题

2025 年做 React 表单,可选方案已经很多。React Hook Form、Formik、Final Form、UI 组件库自带 Form,都能解决一部分重复劳动。

我现在不会排斥表单库,尤其是字段多、组件库集成深、动态数组多的时候,用库可以省很多注册、校验、错误展示的代码。

但我也不会因为“复杂表单”四个字就立刻上库。真正要先想清楚的是:

  • 字段结构是不是稳定?
  • 校验规则在哪里维护?
  • 异步校验如何取消或防竞态?
  • 服务端字段错误如何回填?
  • 提交流程有没有明确阶段?

这些问题没想清楚,换一个库也只是换一种写法继续乱。

我最近更关注的趋势是 schema、服务端校验和前端表单之间怎么对齐。很多团队希望一份规则前后端复用,这个方向是对的,但落地时仍然要处理用户交互:什么时候提示、提示在哪里、旧请求怎么取消、草稿怎么保留。技术方案能降低重复,不能替你设计体验。

复杂表单是我这几年最能感受到“前端不是只写页面”的地方。

它连接了数据结构、业务规则、接口错误、用户反馈和提交流程。写得短不一定好,能持续改才是真的好。

如果让我重新写那类配置表单,我会先把值、字段状态、错误、流程这四类分清楚,再写 UI。这几类想清楚后,后面加字段、加规则、加步骤,心里会踏实很多。