React 复杂表单:我为什么不再一路 useState 写到底
我以前写 React 表单,很喜欢从 useState 开始。
一个字段一个状态,字段少的时候非常顺手:
1const [name, setName] = useState(""); 2const [email, setEmail] = useState(""); 3const [loading, setLoading] = useState(false);
后来做配置平台和审批类页面多了,我对表单的看法变了。业务规则会一层一层叠上来,这才是复杂表单真正麻烦的地方,怎么拿到输入值反而是最简单的一步。
具体到我手上的一个商品配置表单。第一版只有基础信息、价格和几个开关。后面陆续加了规格组合、渠道差异价、草稿保存、导入校验、异步查重、分步骤提交。每次需求都不大,但半年之后,表单已经变成一个没人愿意碰的组件。
具体到代码上,最早那版长这样:组件顶部排着十几个 useState,往下是一堆 useEffect 做联动——价格变了要重算含税价,规格变了要清空渠道价,开关打开要展示一块新区域。每个 useEffect 的依赖数组里都挤着五六个变量,改一个状态经常触发一连串我自己都没完全预料到的更新。最离谱的一次,我在 onChange 里 setPrice,价格的 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_changed、price_recalculated、submit_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}
字段级校验、整表校验、服务端校验要分开,但规则来源尽量统一。否则后面会出现很怪的问题:页面上显示通过了,提交时又被挡住;或者提交成功了,字段旁边还挂着旧错误。
异步校验最容易让页面“像坏了一样”
用户名、编码、邀请码、配置名称,这些字段经常要异步查重。
我以前只关心请求成功后怎么展示,但竞态其实更危险:
- 用户输入
A,发起查重。 - 用户立刻改成
AB,又发起查重。 AB先返回通过,A后返回重复。- 页面显示当前值重复,但其实重复的是旧值。
这种问题用户说不清,只会觉得页面不可信。
我的处理方式通常是取消旧请求,或者至少只接收最后一次结果。
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。这几类想清楚后,后面加字段、加规则、加步骤,心里会踏实很多。