[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"post-content-react-19-useformstatus-why-pending-stays-false":3},"\u003Cp>Last month I deleted forty lines of prop drilling and felt like a genius. The form had a submit button buried three components deep, and getting \"is the form saving?\" down to that button meant threading a prop through every layer, adding an eslint-disable comment or two, and squinting at the type definition every time I touched it. React 19 shipped \u003Ccode>useFormStatus\u003C\u002Fcode>, and my fix was one hook call and a delete key.\u003C\u002Fp>\n\n\u003Cp>Then I clicked the button and nothing happened. No spinner, no disabled state, no feedback. The request went out fine, but the button just sat there, and my genius refactor suddenly looked like a bug I'd introduced.\u003C\u002Fp>\n\n\u003Cp>That's the part nobody on the release blog posts tells you: \u003Cstrong>useFormStatus only reports on the form it lives inside — and if it isn't inside one, it doesn't throw. It quietly returns \u003Ccode>pending: false\u003C\u002Fcode> forever.\u003C\u002Fstrong> This post is about why that happens, the three ways I've seen it bite people, and the form pattern that actually works.\u003C\u002Fp>\n\n\u003Ch2>What useFormStatus actually is\u003C\u002Fh2>\n\n\u003Cp>Before React 19, submitting a form with async work meant one of three things:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>Lift an \u003Ccode>isSubmitting\u003C\u002Fcode> state up and pass it down through every component in the tree (the prop drilling we're trying to escape)\u003C\u002Fli>\n\u003Cli>Create a context for a single boolean\u003C\u002Fli>\n\u003Cli>Reach for a form library that manages it for you\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>React 19 added form \u003Cem>actions\u003C\u002Fem> — you pass a function to the form's \u003Ccode>action\u003C\u002Fcode> prop instead of wiring up \u003Ccode>onSubmit\u003C\u002Fcode> and calling \u003Ccode>preventDefault()\u003C\u002Fcode> by hand. And it added three hooks that hang off that system:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Ccode>useActionState\u003C\u002Fcode> — runs the action and gives you its return value plus a pending flag in the component that renders the form\u003C\u002Fli>\n\u003Cli>\u003Ccode>useOptimistic\u003C\u002Fcode> — lets you show the result before the server confirms it\u003C\u002Fli>\n\u003Cli>\u003Ccode>useFormStatus\u003C\u002Fcode> — reads the status of the \u003Cem>parent\u003C\u002Fem> form, for descendants that are too deep to receive props\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>\u003Ccode>useFormStatus()\u003C\u002Fcode> returns an object with four fields: \u003Ccode>pending\u003C\u002Fcode> (a boolean), \u003Ccode>data\u003C\u002Fcode> (the FormData of the last submission), \u003Ccode>method\u003C\u002Fcode> ('get' or 'post'), and \u003Ccode>action\u003C\u002Fcode> (a reference to the function passed to the form). For a submit button, 95% of the time you only care about \u003Ccode>pending\u003C\u002Fcode>:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">import { useFormStatus } from 'react-dom'\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus()\n  return (\n    &lt;button type=\"submit\" disabled={pending}&gt;\n      {pending ? 'Saving…' : 'Save changes'}\n    &lt;\u002Fbutton&gt;\n  )\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Notice the import: it's \u003Ccode>react-dom\u003C\u002Fcode>, not \u003Ccode>react\u003C\u002Fcode>. Easy to miss, and the linter will happily let you import the wrong one and hit \u003Ccode>undefined\u003C\u002Fcode> at runtime.\u003C\u002Fp>\n\n\u003Ch2>Why it sat there returning false\u003C\u002Fh2>\n\n\u003Cp>Here's the entire contract of the hook, straight from the docs: \u003Cem>useFormStatus only returns status information for the parent form. It will not return status info for any form rendered in the same component or children.\u003C\u002Fem>\u003C\u002Fp>\n\n\u003Cp>So the hook reports \u003Ccode>pending: false\u003C\u002Fcode> in three situations, and none of them warn you.\u003C\u002Fp>\n\n\u003Ch3>1. You're using onSubmit, not an action\u003C\u002Fh3>\n\n\u003Cp>If your form still does the classic dance — \u003Ccode>onSubmit={handleSubmit}\u003C\u002Fcode> with \u003Ccode>e.preventDefault()\u003C\u002Fcode> and a fetch inside — then there is no form action, and \u003Ccode>useFormStatus\u003C\u002Fcode> has nothing to track. It can't know your fetch is in flight. It will report false for the entire lifecycle, and your button will never disable.\u003C\u002Fp>\n\n\u003Cp>This is the version that bit me. My button component was fine. My form was the problem — it predated React 19's actions, and I'd only updated the button.\u003C\u002Fp>\n\n\u003Ch3>2. The button isn't a descendant of the form\u003C\u002Fh3>\n\n\u003Cp>The hook reads context provided by the nearest parent \u003Ccode>&lt;form&gt;\u003C\u002Fcode>. Any component between the form and the button is fine — that's the whole point. But if the button is a \u003Cem>sibling\u003C\u002Fem> of the form — say your design puts the submit action in a sticky footer bar outside the form element — you get \u003Ccode>pending: false\u003C\u002Fcode> forever. That's the \"prop drilling is annoying but at least it works\" tax.\u003C\u002Fp>\n\n\u003Ch3>3. You called it in the same component that renders the form\u003C\u002Fh3>\n\n\u003Cp>React can't report on a form rendered in the same component the hook is called from — the form's context isn't available to its own render. If you need the pending flag where the form lives, use \u003Ccode>useActionState\u003C\u002Fcode> instead, which hands it to you directly.\u003C\u002Fp>\n\n\u003Ch2>The pattern that works\u003C\u002Fh2>\n\n\u003Cp>Here's the setup I use now. The form owns the action and its state; the button, wherever it lives in the tree, just reads the status:\u003C\u002Fp>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F PostForm.jsx\nimport { useActionState } from 'react'\nimport { SubmitButton } from '.\u002FSubmitButton'\n\nasync function createPost(formData) {\n  const title = formData.get('title')\n  \u002F\u002F ...validate, save, return { ok: true } or errors\n}\n\nexport function PostForm() {\n  const [state, formAction] = useActionState(createPost, { errors: {} })\n\n  return (\n    &lt;form action={formAction}&gt;\n      &lt;input name=\"title\" \u002F&gt;\n      &lt;Editor name=\"body\" \u002F&gt;\n      &lt;SubmitButton \u002F&gt;   {\u002F* any depth *\u002F}\n    &lt;\u002Fform&gt;\n  )\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cpre>\u003Ccode class=\"language-javascript\">\u002F\u002F SubmitButton.jsx\nimport { useFormStatus } from 'react-dom'\n\nexport function SubmitButton() {\n  const { pending, data } = useFormStatus()\n  return (\n    &lt;button type=\"submit\" disabled={pending}&gt;\n      {pending ? `Saving \"${data?.get('title')}\"…` : 'Publish post'}\n    &lt;\u002Fbutton&gt;\n  )\n}\u003C\u002Fcode>\u003C\u002Fpre>\n\n\u003Cp>Three details that matter:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>The button must be \u003Ccode>type=\"submit\"\u003C\u002Fcode>. A button without a type defaults to \u003Ccode>submit\u003C\u002Fcode> inside a form, but the moment you move it into a toolbar component, someone gives it \u003Ccode>type=\"button\"\u003C\u002Fcode> and the action never fires.\u003C\u002Fli>\n\u003Cli>\u003Ccode>data\u003C\u002Fcode> is the FormData of the \u003Cem>last\u003C\u002Fem> submission — handy for showing which title is being saved, but it's stale until the first submit.\u003C\u002Fli>\n\u003Cli>The action can be async. \u003Ccode>pending\u003C\u002Fcode> stays true until the promise resolves or rejects.\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Ch2>useFormStatus vs useActionState: pick the right one\u003C\u002Fh2>\n\n\u003Cp>People keep asking which one they should use, and the answer is usually \"both.\"\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>Use \u003Ccode>useActionState\u003C\u002Fcode> in the component that renders the form. You get the action's return value (validation errors, saved IDs) plus \u003Ccode>isPending\u003C\u002Fcode>.\u003C\u002Fli>\n\u003Cli>Use \u003Ccode>useFormStatus\u003C\u002Fcode> in any descendant that needs to react to submission — submit buttons, spinners, \"uploading…\" labels, disabling an image drop zone while the action runs.\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>If you're thinking \"I'll just use useActionState everywhere,\" you can't — it only returns state for the action you passed it, in the component where you called it. A button three levels down doesn't have access to that hook's return value without props. That's literally the prop drilling problem this hook was built to solve.\u003C\u002Fp>\n\n\u003Ch2>The small print\u003C\u002Fh2>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>Portals work.\u003C\u002Fstrong> useFormStatus is context-based, so it follows the React tree, not the DOM. A button rendered into a portal still gets the status. This surprised me, but it's correct.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>SSR is fine.\u003C\u002Fstrong> During server rendering the hook returns the defaults (\u003Ccode>pending: false\u003C\u002Fcode>, \u003Ccode>data: null\u003C\u002Fcode>), then updates on the client. No hydration warnings.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A child form creates a new boundary.\u003C\u002Fstrong> The hook reports on the nearest parent form. Compose a search form inside a bigger component and any button inside it reports that form's status, not yours. (Nested forms aren't valid HTML anyway — this is about component composition.)\u003C\u002Fli>\n\u003Cli>\u003Cstrong>It's a status, not a controller.\u003C\u002Fstrong> You can't cancel the action or reset the form from here. Reset handling belongs in the action itself.\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Ch2>Where it still falls short\u003C\u002Fh2>\n\n\u003Cp>Honest assessment after a month of using it in production:\u003C\u002Fp>\n\n\u003Cul>\n\u003Cli>\u003Cstrong>No per-field status.\u003C\u002Fstrong> You get one boolean for the whole form. If you want \"title is validating\" vs \"image is uploading,\" you're back to manual state or a library.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No progress.\u003C\u002Fstrong> \u003Ccode>pending\u003C\u002Fcode> is binary. For a large upload, users see a spinner with no percentage unless you track it yourself with fetch streams or XMLHttpRequest.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>No error information.\u003C\u002Fstrong> Errors are whatever your action returns — useActionState's state, or a throw that an error boundary catches. The hook tells you nothing about failure.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Double-submit protection is on you.\u003C\u002Fstrong> Disabling the button on \u003Ccode>pending\u003C\u002Fcode> covers the common case, but Enter-key submissions in some browsers can still double-fire. The robust fix is an idempotency key inside the action.\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Ch2>My advice\u003C\u002Fh2>\n\n\u003Cp>Adopt form actions on your next form — even a tiny contact form — so you learn the mental model on something small. Keep the submit button as a descendant of the form. And if you ever see a button that should be disabled but isn't, run through the three traps above before you start sprinkling state around: onSubmit instead of an action, sibling instead of descendant, or the hook in the wrong component.\u003C\u002Fp>\n\n\u003Cp>React finally made form state boring, and that's a good thing — one less boolean threading through your components. I've saved the full action + useActionState + useFormStatus setup, the exact code above, as a snippet in \u003Ca href=\"https:\u002F\u002Fdevspera.com\u002Fsnippetark\u002F\">Snippet Ark\u003C\u002Fa> so I don't have to rewrite it from memory on every project. What's the form pattern you keep reimplementing? I'd bet there's a snippet for it too.\u003C\u002Fp>\n",1787133717904]