【硬核实战】React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It (2026-10-03)

React 19 useFormStatus Returning False? I Built a SubmitButton That Fixes It

在现代软件工程与前端架构演进中,掌握深层原理与实战编码技巧是提升技术壁垒的核心。本文将结合真实业务场景,通过完整代码实现与底层机制拆解,带你全面攻克这一核心技术点。


A few weeks back I wrote about the useFormStatus bug that got me the hardest, the one where pending sits at false forever because the hook was called in the wrong component. If you haven't read that one, the short version is this: the hook only reports on a parent form, never one rendered by the same component calling it, and nothing warns you when you get it wrong. No error, no console message, the button just quietly never enters a pending state.

That post explained the bug. This one is about what I actually built once I got tired of re-explaining the same fix to myself on every new project, and about a second version of that exact same silent failure I didn't notice until I'd already shipped the first fix.

One scope note before the code: this fixes the placement mistake. If pending is false because the form submits through a plain onSubmit handler that only calls preventDefault(), or through a URL string action, moving the hook won't change anything, because there is no Action for it to track.

What the first post didn't cover

Moving the hook fixes the placement bug, but I ran into two more problems as soon as I used it in real forms.

The first is disabled={pending} on the button itself. It works, and it's also the wrong choice if you care about keyboard users. A disabled element drops focus the instant it becomes disabled. Someone tabs to your submit button, hits Enter, and focus jumps away mid submission, sometimes landing nowhere useful at all. The fix is aria-disabled instead of disabled, with the click blocked manually so repeat submissions still get ignored.

jsx 复制代码
function SubmitButton({ children, pendingText, intent }) {
  const { pending } = useFormStatus();

  function handleClick(e) {
    if (pending) e.preventDefault();
  }

  return (
    <button
      type="submit"
      name={intent ? "intent" : undefined}
      value={intent}
      aria-disabled={pending || undefined}
      aria-busy={pending || undefined}
      data-pending={pending ? "" : undefined}
      onClick={handleClick}
    >
      {pending ? pendingText : children}
    </button>
  );
}

Focus stays exactly where it was. Style the pending state off aria-disabled or data-pending, not :disabled, since the native attribute is never actually set here.

The second problem shows up once a form has two submit buttons. pending belongs to the whole form, not to whichever button was clicked, so a Save Draft and a Publish button next to each other both light up for one click. The browser already knows which button submitted. Its name and value are in the FormData, so you read them back out and compare.

jsx 复制代码
function useFormStatusForIntent(intent) {
  const status = useFormStatus();
  const submittedIntent = status.data?.get("intent");
  const isThisIntentPending =
    status.pending &&
    (intent === undefined || submittedIntent == null || submittedIntent === intent);
  return { ...status, isThisIntentPending };
}
jsx 复制代码
<form action={savePost}>
  <textarea name="body" />
  <SubmitButton intent="draft" pendingText="Saving draft...">Save Draft</SubmitButton>
  <SubmitButton intent="publish" pendingText="Publishing...">Publish</SubmitButton>
</form>

The button shown earlier is simplified to keep the focus on aria-disabled. In the full file, SubmitButton calls this hook, so only the button someone actually pressed shows pending. One gotcha before you wire this up: pressing Enter in a text field submits through whichever button comes first in the markup. That's the HTML spec's definition of the default button, not something React decides. Put your safest action first.

Locking the rest of the form while it submits

Next you'll probably want the rest of the form locked while the request is in flight, so nobody edits a field mid submit. A native <fieldset> does this in one line instead of one disabled prop per input.

jsx 复制代码
function PendingFieldset({ children, unstyled }) {
  const { pending } = useFormStatus();
  return (
    <fieldset
      disabled={pending}
      style={unstyled ? undefined : { border: 0, margin: 0, padding: 0 }}
    >
      {children}
    </fieldset>
  );
}

The unstyled flag is for CSP. The inline reset removes the fieldset's default border, but a CSP that blocks inline styles ignores it on server-rendered HTML and the border comes back. If that's you, pass unstyled and style it with a class.

One trade-off: a focused input loses focus when the fieldset disables it, the same thing that happens to a disabled button. Use it where blocking edits matters more than keeping focus.

For a screen reader announcement, aria-busy alone isn't read out loud. You need an actual live region for that.

jsx 复制代码
function FormStatusText({ pendingText, idleText = null }) {
  const { pending } = useFormStatus();
  return (
    <span role="status" aria-live="polite" aria-atomic="true">
      {pending ? pendingText : idleText}
    </span>
  );
}

The bug I didn't catch until the second pass

Here's the part that made me go back and touch the file again after I thought it was already done.

SubmitButton had a dev-only check from the start. Render it outside a form and you get a console error in development explaining why it won't work.

PendingFieldset and FormStatusText had the identical requirement, "must be rendered inside the form", written right there in their own comments, but neither one actually had the check. Misplace either one and it fails as silently as the original bug: the fieldset never disables, the status message never appears, and nothing warns you.

It's an easy mistake to make, since these two feel less important than the submit button and tend to get dropped in wherever is convenient.

Both now run the same placement check the button already had, with the same escape hatch for anyone using a portal on purpose:

jsx 复制代码
<PendingFieldset unsafeSkipFormCheck>
  {/* rendered through a portal: outside the form in the DOM, inside it in the React tree */}
</PendingFieldset>

The check has a limit. It only looks at the DOM, but useFormStatus follows the React tree. A component rendered through a portal can track the form correctly and still trigger the warning, because in the DOM it isn't inside the form. That's what unsafeSkipFormCheck is for.

Where this landed

The full file has the button, the fieldset, the live region, the intent hook, and the placement check on all three components. One file, no dependencies beyond react and react-dom.

Known limits: intent doesn't work with a per-button formAction, form.requestSubmit() skips the click guard, and two synchronous clicks in the same task both get through before React re-renders. I've only tested in Chromium and Firefox so far. Safari and screen readers are still untested.

If you want the whole thing: useFormStatus SubmitButton. Free, single file.

If you're catching this one first and want the full explanation of why the original bug happens, that's the earlier post.


💡 核心总结与实践建议

代码的优雅不仅体现在语法的精练,更在于对底层执行机制、边界条件与异常处理的深度把握。在实际项目中落地类似功能时,建议结合自身业务的数据流规模与性能指标(如 LCP、INP 等)进行综合压测评估。

你在日常项目中遇到过类似的技术挑战吗?大家通常是如何优化和解构的?欢迎在评论区分享你的实战经验! 💬🚀

相关推荐
treasure1407 小时前
【复古】浅尝Django前后端不分离
前端·css·python·django·bootstrap·html·web
jay神8 小时前
基于 YOLOv8 的手势识别系统(Web 版)
前端·人工智能·python·深度学习·yolo·计算机视觉·计算机毕业设计
屏什么录8 小时前
长时间录屏中途崩溃:MP4 打不开的 6 种救回方案与参数设置
前端·音视频开发
code_Bo9 小时前
不只命令行:我把跨 Agent 会话同步做成了桌面应用
前端·javascript·后端
kyriewen9 小时前
AI 能把页面写出来,为什么改个需求就开始崩?
前端·javascript·人工智能
OpsEye9 小时前
同一套用例跑两个大模型接口,差异到底出在哪
javascript·ai编程
X1A0RAN10 小时前
PPT 图片批量处理教程:导出全部幻灯片 · 提取全部素材
开发语言·javascript·powerpoint
涛涛ing10 小时前
2026年最值得关注的前端变化,藏在“默认值”里
前端
量化分析码农11 小时前
股票历史数据的复权参数对照:多级别返回的字段与数值差异
java·javascript·后端
亿元程序员12 小时前
太牛了!这AI做出来的游戏,居然还能变现!
前端