Our app has 18 <form> tags across 16 files, every one of them posting to a server action. I went looking for how each of them tells you it is working, and found five different answers, one of which is "it does not".
The five patterns
One: a shared component wrapping useFormStatus. It exists, it is good, and it is imported by two files:
export function FormSubmitButton({ icon, children, className, ...props }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className={className} {...props}>
{icon && (
<span className="inline-flex shrink-0">
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
</span>
)}
{!icon && pending && <Loader2 className="h-4 w-4 animate-spin shrink-0" />}
{children}
</Button>
)
}
Its own doc comment states the rule it is built around: the leading icon is replaced by a spinner while pending, and the label text stays put, so the button does not change width mid-submit. Three call sites, in the two pack editor pages.
Two: useFormStatus inlined into a local sub-component. Three files do this, each with its own flavour. The sign-up form swaps the label instead of the icon:
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button className="w-full mt-4" type="submit" disabled={pending}>
{pending ? 'Creating account…' : 'Sign up'}
</Button>
)
}
Which is to say it breaks the shared component's one rule, in the same codebase, four lines of code later. In practice it gets away with it because the button is w-full, so a wider label cannot move anything.
Three: useTransition. Two files, where the submit is not the only thing that has to go quiet. One of them binds isPending to eight different controls, because a half-finished dialog with one spinner and seven live buttons is worse than no spinner.
Four: a useState boolean. One form, set manually around an async call. It predates the others, and it is the version every React developer writes before learning useFormStatus exists.
Five: nothing. Eight of the sixteen files render a plain <Button type="submit"> and no pending anything. That list includes the sign-in form, the forgot-password form, the password-reset form, the sign-out form in the header menu, and three of the four account settings forms.
So the app has a purpose-built component for this exact problem, and the four forms a stranger is most likely to meet are the ones not using it.
Pending state and error state are not the same hook
The eight forms with no pending feedback are not careless. They all use useActionState, which is why they feel finished:
const [formState, formAction] = useActionState(loginUser, { message: '' })
That gives you the action's return value, so the form can raise a toast when sign-in fails. useActionState also returns a pending flag as its third element, which these call sites never destructure, because the thing they were written to solve was error display.
useActionState answers "what came back". useFormStatus answers "is something in flight", and it answers it from inside the form, which is why it wants a child component. Having solved the first question, it is very easy to believe you have solved the second.
Reading which pattern a page shipped, from outside
You do not need the source for this. Both pages are public, and the client chunk carries the answer.
Load pub-trivia.app/signup, open the Network tab, filter to JS, and search the chunks for Creating account. One hit, in a 3,254 byte chunk, and the surrounding code is the whole pattern in one line:
let {pending:a} = (0,l.useFormStatus)(),
o = a ? "Creating account…" : "Sign up";
Now do the same on pub-trivia.app/login, searching for Sign In. The compiled form is there, built from the same primitives, and its button has no binding at all:
(0,r.jsx)(n.Button, { className:"w-full mt-4", type:"submit", children:"Sign In" })
Fifteen JS files on each page. One of them changes its label while a server action runs and one of them cannot, and the diff is visible without reading a line of our repository.
While you are in there, note the (0,r.c)(3) call at the top of those components. That is the React Compiler's memo cache, which this project turns on with reactCompiler: true in next.config.mjs. It is a good reminder that none of this is about saving renders. The compiler already handles that. The missing piece in those eight forms is purely what the human sees in the second after they click.
The fix, and why it is not one line
FormSubmitButton is a drop-in, so each of the eight is a two-line change: import it, swap the <Button type="submit">. The reason it is still a list of eight is that the three sign-in and reset forms sit in client components that were written before the shared button existed, and each of them has to be checked for the layout-shift rule the shared component promises and the local copy ignores. A button that is w-full can swap its label safely. A button sized by its text cannot, and three of these are not full width.
The real lesson is cheaper than the fix: if your codebase has a component whose entire job is pending state, grep for its import count before you trust that the problem is solved. Ours said two.
Forms to poke at on the live site
- pub-trivia.app/login and pub-trivia.app/signup are the two sides of this post. Same primitives, same server action plumbing, different answers to "is it working", and you can tell which is which from the shipped JavaScript.
-
pub-trivia.app/tools/scoresheet-generator is the control case: four labelled inputs and a button that produces a printable PDF entirely in your browser. Nothing is posted anywhere, so there is no pending state to show, and the form is honest about that by having none. It is also the quickest way to see what these three primitives,
Label,InputandButton, do without any of the hooks above.
Top comments (0)