又是一个周五下午,我盯着那个灰色的“注册”按钮——它已经禁用了整整三秒,但表单没有任何返回。用户大概率连点了两下,只不过 UI 上没表现出来。那个瞬间我突然意识到,过去几年我亲手给每个表单加上的isSubmitting状态,可能根本没有真正阻止重复提交。

事情的转折点,是某个新版本悄悄带来了一个名字很直白的 Hook:useActionState。起初我以为它只是又一项需要背诵的 API,直到我把旧代码摊开,才发现自己一直在用一个看似安全、实则随时会出问题的补丁。

打开网易新闻 查看精彩图片

我们用了那么多年的三板斧

在此之前,几乎每一个我交付出去的表单,都带着相同的三块状态拼图:errorisSubmittingresult。它们像约定俗成的模板一样,出现在每一个登录框、每一个注册流程、每一个下单按钮后面。写法大同小异:

function OldSignupForm() {const [error, setError] = useState(null);const [isSubmitting, setIsSubmitting] = useState(false);async function handleSubmit(e) {e.preventDefault();setIsSubmitting(true);setError(null);try {await signup(new FormData(e.target));} catch (err) {setError(err.message);} finally {setIsSubmitting(false);return {/* ... */};}

一个try/catch/finally套住整个异步流程,提交前把按钮锁死,请求结束后再重新打开。这么做看起来天衣无缝,我自己也写了不下两年,还常常在代码评审里提醒同事:“别忘了在finally里把按钮放开。”

然而这个长久以来的“最佳实践”,在我认真翻阅新 API 的行为后,轰然倒塌。

正方观点:有isSubmitting就安全了吗?

支持传统方案的人会列出三条理由。第一,本地布尔标志足以防止 UI 层的重复操作——只要按钮一灰,用户就点不了第二次。

第二,写法简单,所有状态由开发者完全掌控,不会受框架黑盒影响。第三,对绝大多数快速网络环境而言,finally里的重置会迅速完成,用户根本来不及点第二下。

所以“禁用一个按钮就足够了”的说法,在很多团队里都是默认共识。更微妙的是,这种习惯还催生了一个变体:直接操作event.target去手动设置disabled属性,而不是通过声明式的状态。种种变体都在试图证明——只要我把按钮变灰,重复提交就不存在。

反方证据:在慢网下,你其实什么都没拦住

useActionState暴露出来的,恰恰是这些变体的集体缺陷:所有基于本地状态的“禁用”,本质上只是在猜测请求什么时候结束,而不是真正和请求的生命周期绑定。

框架并没有修改事件队列的行为,它只是让一个早就存在的底层逻辑变得无处遁形:浏览器的表单提交事件会排队,合成事件不会吞掉用户的连续点击,它只会把每个事件放进队列里,一个接一个地执行。

这意味着什么?在一个可靠的 WiFi 环境下,请求 300 毫秒就返回,本地setIsSubmitting(false)几乎瞬间触发,按钮重新点亮,用户的每一次点击都落在不同的handler调用上,一切安好。

但如果换成一趟穿过隧道的通勤地铁,网络抖动让你的请求卡了整整三秒。本地标志早在请求真正完成前就恢复了,按钮在用户眼中短暂地亮了一下。那个瞬间,用户再点一次“提交”,于是第二个请求进入队列,接着第三个。

每一个点击都被忠实地执行,每一个请求都被服务器当作全新的订单处理。最终不是一条重复提交,而是三条。

真正的绑定,知道请求何时真正结束

useActionState没有引入新的魔法。它做的唯一一件事,就是把“请求正在进行中”这个信息,从开发者的猜测变成了框架层面的事实。它直接返回一个isPending字段,这个字段与当前正在飞行的异步操作严密绑定——请求没有落定,它永远不会翻回false

换句话说,慢网络下的那一闪不会出现。按钮的禁用不再依靠一个临时变量和finally里的乐观重置,而是与真实的数据流同步。用户点得再快,看到的 UI 也始终是稳定的“正在提交”。

我把这个新 Hook 放回自己那个用了一年的下单表单里,删掉了手写的isSubmitting标志,删掉了try/catch/finally里的重置逻辑。测试同事在 3G 限速下连点十次,只产生了一条订单。那一刻,我才觉得自己真正防住了重复提交。

别再高估那个灰色的按钮

这次经历给我的教训不是“又有一个新 API 要学”,而是:任何不与真实异步流程绑定的禁用,都只是在赌用户的网络足够快。

当你下一次习惯性地写下setIsSubmitting(true)时,不妨多问一句——这个true,真的代表请求还没结束吗?