成为全栈·React 管理后台篇·表单页范式:校验、数据回填与未保存保护
表单可靠性不只取决于校验规则。远端数据什么时候写入、什么算已保存、离开页面时是否仍有任务进行,决定了用户会不会丢掉已经完成的工作。

前言
管理后台表单最让人恼火的 bug,往往不是"必填项没提示",而是写好的内容突然没了。
一次真实返工中,文章详情由 Query 拉取,effect 监听 query.data 并调用 form.reset()。首次进入编辑页时一切正常。后来缓存失效触发后台重取,新的 data 对象到达,effect 再次 reset,编辑者刚写的几段正文被服务端旧版本完整覆盖。
从接口角度看,页面只是"同步了最新数据";从用户角度看,系统毁掉了尚未保存的劳动。可靠表单必须区分服务端基线和当前工作副本,并把加载、编辑、提交、上传与离开看成一条状态流程。
本文要解决什么
- 为什么异步数据不能随着每次更新不断 reset 表单。
- dirty 的真正含义是什么,保存成功后怎样重建基线。
- 校验错误位于隐藏面板时,页面如何把用户带到问题位置。
- 应用内跳转、浏览器刷新与关闭窗口怎样统一保护。
- 保存或图片上传过程中,为什么不能允许强制离开。
前置阅读:服务端状态、会话状态、界面状态:不要都塞进 Zustand、列表页范式:让分页、筛选和返回位置进入 URL
表单里同时存在两个版本
编辑已有文章时,至少有两份数据:
- 服务端基线:上一次加载或保存成功后,服务器确认的版本;
- 工作副本:用户正在输入、可能尚未通过校验的版本。
Query 负责第一份,React Hook Form 负责第二份。首次加载时可以从基线创建工作副本,但用户开始修改以后,后台重取不能自动夺回控制权。
最危险的写法是:
ts
// ❌ 每次 query.data 身份变化都覆盖表单
useEffect(() => {
if (query.data) form.reset(articleToForm(query.data))
}, [query.data, form])
即使接口返回内容没变,缓存失效后的新对象也可能触发 effect。简单增加 !form.formState.isDirty 可以降低风险,但用户尚未触碰字段时,后台结果仍可能改变当前上下文;多个文章 id 之间切换也需要明确区分。

一次编辑会话只自动回填一次
文章编辑 hook 使用 ref 记录已经装载过的文章 id:
ts
const hydrated = useRef<string | undefined>(undefined)
useEffect(() => {
if (!id && hydrated.current) {
hydrated.current = undefined
setSaved(undefined)
setSavedAt('')
form.reset(articleToForm())
}
if (id && query.data && hydrated.current !== id) {
form.reset(articleToForm(query.data))
hydrated.current = id
setSaved(query.data)
}
}, [id, query.data, form])
第一次拿到 id 对应的数据时回填;同一 id 后台重取时,不再覆盖;从编辑页切到新建页时,明确清空旧基线。这里的 hydrated 不是"数据是否加载"的通用标志,而是当前编辑会话已经接受过哪个服务端版本。
这套策略有明确代价:如果另一个管理员在后台修改了同一篇文章,当前编辑页不会自动用新数据覆盖。更完整的协作系统应该提示"服务器已有更新",让用户选择重新加载或比较差异。静默覆盖永远不应该是默认解决方案。
dirty 是相对基线,而不是"输入框发生过事件"
React Hook Form 的 isDirty 会把当前值与最近一次 defaultValues/reset 的值比较。因此保存成功后必须用服务器返回结果 reset:
ts
const acceptSaved = (article: Article) => {
form.reset(articleToForm(article))
setSaved(article)
setSavedAt(new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}))
hydrated.current = String(article.id)
}
为什么不用提交前的 values?因为服务器可能补 slug、更新时间、状态或规范化后的字段。只有响应中的 article 才是新的可靠基线。
保存失败时则不能 reset。mutation 已经提示错误,表单保留所有输入,让用户修正或重试:
ts
try {
const article = await update.mutateAsync({ id, payload })
acceptSaved(article)
} catch {
// mutation 负责提示;当前输入必须保留
}
这也是"失败恢复"的一部分。报错以后清空表单,再漂亮的错误文案也救不回用户的内容。
新建成功后,要把会话切换成编辑态
新建文章第一次保存后已经拥有真实 id。若页面仍停在 /articles/new,下一次点击保存可能再次调用创建接口,生成两篇重复文章。
接受服务端结果后,页面使用 replace 导航到编辑地址:
ts
if (!isEdit) {
allowNavigation.current = true
navigate(`/articles/${article.id}/edit`, {
replace: true,
state: { from: returnTo },
})
}
replace 避免浏览器返回时回到已经失效的新建会话;allowNavigation 则放行这次程序主动完成的内部切换,否则离开守卫会把保存成功后的导航也当成丢稿风险。
校验错误必须让用户看得见
文章表单分为"正文"和"设置"两个面板。用户停在正文面板点击发布,如果分类为空,错误发生在隐藏的设置面板。只在字段下面渲染红字,页面表面会像按钮失效一样毫无反应。
提交失败回调会检查首批错误属于哪个面板:
ts
const invalid = (errors: FieldErrors<ArticleFormValues>) => {
setActiveTab(
errors.title || errors.content ? 'content' : 'settings',
)
}
const submit = form.handleSubmit(saveValues, invalid)
发布还有一条依赖动作的规则:保存草稿可以暂时不选分类,正式发布必须有分类。因此它不只是 schema 中的静态必填,而是在 publish 分支中明确设置错误并切换面板:
ts
if (publish && !values.categoryId) {
form.setError('categoryId', {
message: '发布前请选择分类',
})
setActiveTab('settings')
return
}
校验的目标不是阻止提交,而是告诉用户下一步该改什么。错误藏在折叠区、标签页或滚动区域之外,就等于没有完成反馈。
离开保护要覆盖两套导航系统
React Router 能拦截应用内部导航,却拦不住关闭标签页、刷新和输入新地址;beforeunload 能处理浏览器离开,却无法提供自定义确认对话框。因此项目同时使用两层保护。
应用内跳转通过 useBlocker:
ts
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
!allowNavigation?.current &&
(dirty || busy) &&
(
currentLocation.pathname !== nextLocation.pathname ||
currentLocation.search !== nextLocation.search
),
)
浏览器级离开注册 beforeunload:
ts
useEffect(() => {
if (!dirty && !busy) return
const prevent = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', prevent)
return () => window.removeEventListener('beforeunload', prevent)
}, [dirty, busy])
现代浏览器会显示自己的通用提示,不能依赖自定义文案。这是平台限制,不需要尝试用弹窗模拟关闭页面。
上传和保存进行中,不能提供"放弃并离开"
dirty 表示有未保存输入,busy 则表示保存或图片上传仍在进行。两者的处理并不完全一样:
- dirty 时可以询问用户是否放弃修改;
- busy 时应提示等待,并禁用强制离开。
图片上传可能稍后把 URL 插入 Markdown。如果此时允许离开,上传请求仍可能成功,附件已经出现在服务器,文章正文却没有引用它。保存请求进行中离开,也会让用户无法判断操作究竟成功没有。
因此确认框在 busy 时把按钮禁用:
tsx
<ConfirmDialog
title={busy ? '操作尚未完成' : '有未保存的修改'}
confirmText="放弃修改并离开"
confirmDisabled={busy}
onConfirm={() => {
if (!busy && blocker.state === 'blocked') blocker.proceed()
}}
/>
这里不是让用户永远无法关闭页面。浏览器仍拥有最终控制权,但应用内部不会主动鼓励一个结果未知的跳转。
防重复提交要覆盖 React 状态更新间隙
只依赖 mutation 的 isPending,快速双击时可能有一个渲染间隙:第一次调用已经开始,按钮尚未收到新的 pending 状态,第二次调用又进来了。
编辑 hook 额外使用同步 ref:
ts
if (busy || saving.current) return
saving.current = true
try {
await save()
} finally {
saving.current = false
}
状态负责渲染,ref 负责同一事件循环里的立即互斥。对于创建文章这类非幂等写操作,这层保护比事后删除重复记录便宜得多。
适用边界
简单设置表单没有远端异步加载,也没有图片上传,一套 schema 加提交按钮就够了。文章编辑器这种长表单则需要明确基线、离开保护和连续保存语义。
离开保护也不是数据恢复。更长的写作任务可以增加本地自动草稿、服务端版本和冲突合并;这些能力仍应建立在清楚的基线之上,否则自动保存只是更频繁地覆盖错误版本。
小结
可靠表单维护的是一次编辑会话:服务端数据只在合适时机建立工作副本,用户输入成为独立状态,保存成功后用服务器结果重建基线,失败时保留输入;隐藏错误被带到眼前,内部导航和浏览器离开都得到保护,上传与保存完成前不允许悄悄离场。
各位看官,表单测试别只填一遍正确数据。请在接口后台重取时继续输入,制造一次保存失败,从新建状态连续保存两次,再带着未完成上传离开页面。用户的内容始终没有丢,表单才算可靠。
延伸阅读
- 列表页范式:让分页、筛选和返回位置进入 URL
- Markdown 编辑器:预览、暗色主题与连续图片粘贴
- 文章管理工作流:保存修改、投稿、发布与下架
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
