Production caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
- Views
- Likes
shubhra.dev
Loading...
A free, drop-in SubmitButton fixing useFormStatus pending: false, with per-button pending, repeat-click protection, and accessible focus handling.
The working examples below use the real use-form-status-fix file. Two panels below intentionally show plain useFormStatus and a native disabled button instead, so you can see what the snippet changes. Every action here is a timer, so no request leaves your browser.
Press either button. Only the one you pressed shows the spinner, and both ignore clicks until the action finishes. Press Enter inside the title field to see which button the browser treats as the default: the first one in the form.
Both forms run the same 1.5 second action. On the left the hook is called in the component that renders the form, so pending never leaves false. Click Save twice on the left and two actions run at once.
Press Tab until a button has focus, then press Enter. A button with the disabled attribute can lose focus when it becomes disabled. SubmitButton uses aria-disabled, so focus stays where it was. What you see depends on your browser.
Focus is on: nothing yet
The same dev-only placement check that protects SubmitButton also runs on PendingFieldset and FormStatusText. Move the status text outside the form and submit, then open your browser console.
Correctly placed: the text updates while the request is in flight.
Give each submit button an intent. The action reads it from FormData, and only that button shows the spinner and its pending text.
While a submission is in flight, every SubmitButton in the form ignores clicks, including the click the browser fires when you press Enter in a text field.
FormStatusText is a polite live region that stays mounted, so a screen reader can announce the change. The button itself only sets aria-busy.
useSubmittedField returns a field's value while the submission is pending, which is enough for a message like Requesting the username.
// Loading...src/components/Love this snippet?
Share it with your friends and colleagues
Get practical tutorials, engineering insights, and new developer resources delivered.
One-click confirmation required. No spam. Unsubscribe anytime.
Help keep Shubhra.dev creating free tutorials, articles, snippets, quizzes, and developer resources for developers everywhere.
Support shubhra.devBrowse production-ready hooks, components, and utilities, built for serious developers.
Browse All SnippetsProduction caching utilities that make Next.js 16 caching production-safe. Type-safe tag registry, dual-invalidation for Server Actions, parallel prefetching, and Suspense boundary enforcement. One file, zero guessing.
A dev-only toolkit that instruments your 'use cache' functions with zero production cost. Catch cache misses, dynamic holes, missing tags, and deprecated invalidation calls. All output goes straight to your terminal.
useFormStatus is the React 19 hook that tells a component whether the form around it is submitting. It is easy to call in the wrong place, and when you do, nothing tells you.
// Wrong: the hook is called in the component that renders the <form>
function ProfileForm() {
const { pending } = useFormStatus(); // false, no matter what the form is doing
return (
<form action={saveProfile}>
<input name="name" />
<button disabled={pending}>Save</button>
</form>
);
}The React docs list two caveats for this hook. It has to be called from a component that is rendered inside a form, and it only reports on a parent form, never on a form rendered by the same component that calls it. Break either rule and you get no error and no warning. The button just never enters a pending state, so you end up checking your Server Action, which was never the problem.
The fix is to move the hook into a child of the form. That is exactly what SubmitButton in this file does, so using it means the mistake cannot happen. The rest of the file covers what people usually write next: pending state for one button out of several, focus, screen reader text, and locking fields while the request runs.
For the fuller explanation, including how useFormStatus compares to useActionState's own isPending, the useFormStatus tutorial walks through it end to end. If you are reviewing a PR or preparing for an interview on this, check out 25 useFormStatus interview questions for full Q&A coverage of these placement edge cases.
You can write a submit button around useFormStatus in ten lines, and for a small form you probably should. This file is for the point where that button keeps growing.
| Need | Plain disabled={pending} button | This file |
|---|---|---|
| Show a pending label | You write it | Included |
| Ignore repeat clicks while pending | Yes, disabled buttons do not fire clicks | Yes, through aria-disabled and a blocked click |
| Keep keyboard focus on the button while pending | In Chromium 153 and Firefox, focus moved to the page body | Included, focus stayed on the button |
| Pending state on the pressed button only, with several submit buttons | You write it | Included, through intent |
| Spinner that stops for reduced motion | You write it | Included |
| A screen reader message for the pending state | You write it | FormStatusText |
| Lock the fields while submitting | You write it | PendingFieldset |
| Warning when the button is outside a form | None | Dev-only console error |
| Export | What it does |
|---|---|
SubmitButton | Submit button that reads useFormStatus itself, with pending text, a spinner, and intent support |
PendingFieldset | A fieldset that disables everything inside it while the form submits |
FormStatusText | A polite live region that shows a message while the form is pending |
useFormStatusForIntent | The per-button pending logic as a hook |
useSubmittedField | Reads a field's value from the submission that is in flight |
FormStatus | The return type of useFormStatus, derived from the hook so it cannot drift |
A few facts about the file itself:
react and react-dom."use client", so you can import it from a Server Component.useFormStatus is a React 19 API.@types/react-dom you have.Put use-form-status-fix.tsx anywhere in your project, for example components/. Check that you are on React 19 and react-dom 19.
Prefer cloning it instead, or want the worked examples that come with it? It's on GitHub.
// components/profile-form.tsx
import { SubmitButton } from "@/components/use-form-status-fix";
import { saveProfile } from "@/app/actions";
export function ProfileForm() {
return (
<form action={saveProfile}>
<input name="name" />
<SubmitButton pendingText="Saving...">Save</SubmitButton>
</form>
);
}ProfileForm can stay a Server Component. Only SubmitButton runs on the client.
While the form is pending, the button gets aria-busy="true", a data-pending attribute, and aria-disabled="true". It shows the spinner and pendingText. When the action finishes, everything goes back.
With two buttons, plain pending is shared, so both buttons would show a spinner for one click. Give each button an intent and only the pressed one shows pending.
<form action={savePost}>
<textarea name="body" />
<SubmitButton intent="draft" pendingText="Saving draft...">
Save Draft
</SubmitButton>
<SubmitButton intent="publish" pendingText="Publishing...">
Publish
</SubmitButton>
</form>// app/actions.ts
"use server";
export async function savePost(formData: FormData) {
const intent = formData.get("intent"); // "draft" or "publish"
const body = formData.get("body");
if (intent === "publish") {
// publish the post
} else {
// save a draft
}
}Pressing Enter in a text field clicks the form's default button, which the HTML standard defines as the first submit button in tree order. So the first button decides what the action receives. Put your safest action first, which here is Save Draft.
// components/username-form.tsx
import {
FormStatusText,
PendingFieldset,
SubmitButton,
} from "@/components/use-form-status-fix";
import { requestUsername } from "@/app/actions";
import { SubmittedUsername } from "./submitted-username";
export function UsernameForm() {
return (
<form action={requestUsername}>
<PendingFieldset>
<input name="username" />
</PendingFieldset>
<SubmitButton pendingText="Requesting...">Request</
// components/submitted-username.tsx
"use client";
import { useSubmittedField } from "@/components/use-form-status-fix";
// Must be rendered inside the <form>
export function SubmittedUsername() {
const username = useSubmittedField("username");
return username ? <p>Requesting {username}...</p> : null;
}PendingFieldset is optional. A focused input loses focus when it becomes disabled, so use it where blocking edits matters more than keeping the cursor in place.
SubmitButton only handles the pending state. To show a result or an error, pair it with useActionState.
"use client";
import { useActionState } from "react";
import { SubmitButton } from "@/components/use-form-status-fix";
import { savePost } from "@/app/actions";
export function PostForm() {
const [state, formAction] = useActionState(savePost, { error: null });
return (
<form action={formAction}>
<textarea name="body" />
<SubmitButton pendingText="Saving...">Save</
With useActionState, your action receives (previousState, formData) instead of just formData.
The button uses aria-disabled while pending, so style it with an attribute selector, not :disabled.
button[aria-disabled="true"] {
opacity: 0.6;
cursor: progress;
}
button[data-pending] {
cursor: progress;
}With Tailwind, the aria-disabled: and data-[pending]: variants do the same job.
Keep the useFormStatus cheatsheet open next to this if you want all four useFormStatus properties and the placement rule on one page while you read the tables below.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode or (status: FormStatus) => ReactNode | required | Button content, or a function that receives the form status |
pendingText | ReactNode | none | Shown instead of children while this button's submission is pending |
intent | string | none | Adds name="intent" and the value to the button, and scopes pending to it |
showSpinner | boolean | true | Shows the SVG spinner while this button is pending |
unsafeSkipFormCheck | boolean | false | Silences the dev-only warning about placement |
disabled | boolean | none | Native disabled attribute, which also sets aria-disabled |
onClick | click handler | none | Called when idle, skipped while a submission is pending |
ref | Ref<HTMLButtonElement> | none | Object refs and callback refs both work, including React 19 cleanup refs |
Every other button prop is passed through, except type, which is always submit.
Attributes it sets:
| Attribute | When |
|---|---|
aria-busy="true" | This button's submission is pending |
data-pending | This button's submission is pending |
aria-disabled="true" | Any submission in the form is pending, or disabled is set |
Returns everything useFormStatus returns, plus isThisIntentPending. It is true while a submission is pending and that submission came from the button whose intent matches. With no argument it is the same as pending. It must be called from a component rendered inside the form.
Returns the string value of a field in the submission that is in flight, or null when the form is idle. File fields return null.
Accepts all fieldset props, plus unstyled and unsafeSkipFormCheck. It sets disabled while the form is pending and adds an inline reset (no border, margin or padding). Pass unstyled to skip the reset and style it with a class. Pass unsafeSkipFormCheck to silence the dev-only placement warning, the same escape hatch SubmitButton uses.
| Prop | Type | Default | Description |
|---|---|---|---|
pendingText | ReactNode | required | Shown while the form is pending |
idleText | ReactNode | null | Shown otherwise |
className | string | none | Class for the wrapping span |
unsafeSkipFormCheck | boolean | false | Silences the dev-only warning about placement |
ref | Ref<HTMLSpanElement> | none | Object refs and callback refs both work |
SubmitButton calls useFormStatus in its own body. Because the button is rendered by the form's component, the hook runs in a descendant of the form, which is the position React requires. You do not have to remember where the hook goes.
While a submission is pending, useFormStatus().data holds that submission's FormData. The browser adds the submitting button's name and value to it. A button with name="intent" and value="publish" therefore adds intent=publish, and each SubmitButton compares that value with its own intent.
If nothing pressed a button, for example when code calls form.requestSubmit() with no argument, there is no intent in the data. In that case every button that has an intent shows pending, so the user still sees that something is happening.
In Chromium 153 and Firefox, a button with the disabled attribute lost keyboard focus when it became disabled, and focus went to the page body. A button with aria-disabled kept focus in both. Keeping focus matters for keyboard and screen reader users, who otherwise lose their place mid-submit.
While any submission is pending, every SubmitButton in the form calls preventDefault on click and skips your onClick. Pressing Enter in a text field works through the same path, because the browser fires a click on the default button. In Chromium 153 and Firefox, Enter and Space did not start a second submission, and a real double click ran the action once.
The spinner is an inline SVG with a SMIL rotation, so it needs no stylesheet and no icon library. When prefers-reduced-motion: reduce matches, it renders without the animation. It only appears after a submit, so it is never part of the server-rendered HTML.
In development, SubmitButton, PendingFieldset, and FormStatusText each log a console error if they are not inside a form element in the DOM. The check only looks at the DOM, not the React tree, so a component rendered through a portal will warn. If you render one through a portal on purpose, pass unsafeSkipFormCheck on whichever one needs it. The check cannot catch a raw useFormStatus() call made in the component that renders the <form>, so use these components instead of the raw hook. The check is off when NODE_ENV is production.
FormStatus is ReturnType<typeof useFormStatus>. In the idle state data, method and action are null, and the type reflects that. A hand-written copy of this type is easy to get wrong, which is why the file derives it.
aria-busy is set for styling and assistive technology, but it is not a reliable way to get a message read out. Use FormStatusText when you want the change announced.FormStatusText renders a span with role="status", aria-live="polite" and aria-atomic="true". It stays mounted, because a live region that appears at the same moment its text changes may not be announced.aria-hidden. The pending text is what carries the meaning.form.requestSubmit() skips the click handler. If your code calls it, guard that call yourself.click() calls dispatched in one synchronous task both go through, because the pending state has not rendered yet. Real user input arrives as separate events and is blocked.intent and a function formAction do not mix. With a per-button function formAction, React leaves the submitter's name and value out of the FormData, so no intent is found and every SubmitButton shows pending for that submission.onSubmit handlers are not tracked. useFormStatus follows form actions. In a test with an onSubmit handler that called preventDefault, pending stayed false while the handler ran. Use the action prop.disabled to it, the HTML standard says pressing Enter in a text field submits nothing.PendingFieldset and strict CSP. Its inline style reset is ignored on server-rendered HTML when your policy blocks inline style attributes. The fieldset then keeps its default border. Pass unstyled and use a class.useActionState.useActionState and useOptimistic for those.Before shipping a form built on this, the useFormStatus checklist walks through these placement and onSubmit mistakes, plus a few more that only show up in code review.
Tested with React 19.3.0.
| Environment | Result |
|---|---|
| jsdom | Behavior tests pass, including refs, intent, guards, PendingFieldset and useActionState |
| Chromium 153 and Firefox | Enter and Space ignored while pending, real double click blocked, aria-disabled kept keyboard focus while a plain disabled button lost it to the page body, intent scoping picked the right button, Enter-key submission routed through the first submit button in tree order, and the placement warning fired correctly on SubmitButton, PendingFieldset, and FormStatusText when misplaced |
| Chromium 153 only | No hydration warning with reduced motion, spinner animates under a strict CSP |
| Next.js 16.3.6 | Production build passes, and a Server Action form goes idle, pending, idle with the right intent |
| Vite | Dev and build both replace process.env.NODE_ENV correctly |
| React 18 | Not supported, because useFormStatus is a React 19 API |
| Safari | Not tested |
| Screen readers | Not tested |
| React 19.0 through 19.2 | Not tested |
pending gives the wrong result, because both buttons react to one click.The React docs give you the hook and a short example. The time goes into the parts around it: which button was pressed, where focus ends up, what a screen reader hears, and what happens under a strict CSP. I wrote those parts down once and tested them in a real browser. Where I could not test something, the page says so. If you find a case that breaks, the limitations section is where it belongs.
Want to check how much of this actually stuck? There's a short useFormStatus quiz if you'd rather test yourself than take my word for it.