跳到主要内容
按钮变灰了,不代表事情办成了

按钮变灰了,不代表事情办成了

今天也没搞懂
今天也没搞懂

· 阅读约 5 分钟

学 React 19 两个多月了。昨晚本来只想改一个收藏按钮——小心形,点一下变实心,再点一下变空心。

就这东西,耗了我一整晚,到现在也没改到满意。

但我想写的不是这个按钮。

我有个夹生的地方,夹了大概三周:我以为 async 函数就是 Action。

字面意义上的以为。我写个 async function,心里就默认"这就是个 Action 了",后面该有的都该有。这个理解三周前就埋进去了,一直没被任何东西打脸,就一直留着。

昨天它被打脸了。

同一段逻辑,我从这儿搬到那儿,发现它有时候能拿到返回值,有时候拿不到;有时候 React 替我管着 pending,有时候不管。

问题不在函数本身。在它是从哪个入口进去的。

startTransition 直接调、用 useTransition 返回的那个 startTransition 调、直接塞给表单的 action、扔给 useActionState——四种入口,同一段异步逻辑,四种脾气。

四种我到现在也记不全,还得翻。

我自己调的那个 startTransition 是不返回结果的,我 await 它也没有东西可以接。useActionState 就是专门接结果的那扇门,pending 和返回值一起给我。

所以我原来以为的"拿不到返回值是我写法不对"——不是,是门不对。

还有个更具体的。同一个函数,从表单那扇门进去,收的是一个 formData;从 useActionState 进去,永远是 previousState 加 formData 两个参数。两个版本不能直接互换,得一开始就照双参数的样子写。

这个我是昨天下午才撞上的,撞完坐在那儿愣了一下——我一直以为 Action 是"一种函数",原来它是"一种调用方式"。

然后是真正把我绊住的那段。

我的收藏按钮没有表单,蹭不到"表单自动把提交包进 Transition"那个便宜。只能在点击处理函数里手动 startTransition,再用 isPending 把按钮禁掉。

大概长这样:

async function handleToggle() {
  startTransition(async () => {
    const next = !saved;
    await toggleSaved(id, next);
    startTransition(() => setSaved(next));
  });
}

<button onClick={handleToggle} disabled={isPending}>
  {saved ? '♥' : '♡'}
</button>

await 后面那层 startTransition 是我照着示例写的,当时根本不知道为什么要包,只知道不包上去 setSaved 就不太对。现在勉强懂一点:React 只自动跟同步那一段,await 之后的东西要重新进一次 Transition 才算数。

这块我到现在也说不太清楚"不太对"具体是哪不对。就是不对。

然后我手快,连点了五下。

按钮是变灰了——但得等它先变灰。我点得比它变灰快。最后服务器上是 true,我屏幕上显示的是空心。

最后落地的值:true
我心形显示的是:♡

Transition 里的 Action 本身不保证执行顺序。这句话文档里有,就一行,之前扫过去的时候完全没停。五个请求谁最后落地,React 不管。

表单 action 和 useActionState 是被自动处理顺序的两种内置方式——那也就是说,只有我这种没表单还非要硬用 useTransition 的写法,才站在没有保证的那一边。

还有个我昨晚才发现的事:示例里用普通 useState 而不是 useOptimistic,是为了单独把 useTransition 讲清楚,不是给我直接搬进项目的。我一直当模板在用。

凌晨在评论区看到一条,大意是 pending、乐观更新、禁用提交这些解决的都是 UI 层的事;重复提交、重试、并发、乱序,跨过服务器边界之后还是要后端用幂等、事务这些东西自己兜住。

第一反应是「这不废话吗」。

第二反应是心虚。因为我这几周心里默认的就是"按钮变灰了 = 事情办成了"。我好像一直把 pending 当成一个礼貌的确认,"我知道了,你等会儿"。

它其实只说明一件事:这个 Transition 还没结束。

没说到没到。会不会重发,它不管。最后落地的是哪一次,它也不管。

React 管的是 UI 的协调。跨过服务器那条线之后是不是安全的、要不要拒绝、要不要排队——那是服务端的契约。

这句话我可能三周前就在哪读到过,没当回事。昨晚它一下子进来了,代价是五下点击。

还剩没搞懂的:

useOptimistic 到底是"入口"还是"setter",我脑子里还是糊的。看到的那篇说它是例外,不是进 Action 的门,是在已经跑起来的 Action 里面调的 setter,在 Transition 外面调就没那个行为。读的时候点头,合上页面自己复述了一遍,复述不出来。那就是没懂。

还有评论区有人问,这个不变量能不能用 lint 强制住,回答是 lint 只能当护栏,不能当真。setter 要是绕几个函数传出去,得分析整条调用链和控制流才能确定。这段我连"为什么类型签名表达不了"都没想明白。

先记下来。

今天的同桌是 Claude Code,我把"为什么 await 之后要再包一次 startTransition"翻来覆去问了三遍,它答得挺清楚,我也点头,然后第二天再想还是要想一会儿。

这算不算夹生?

下一步想把收藏按钮用 useOptimistic 重写一遍,看看五次连点会变成什么样。可能还是一样乱。但至少能让我分清哪部分是 React 管、哪部分是我自己欠的。