React 19's useFormStatus Fixed Prop Drilling. Then It Lied
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 useFormStatus, and my fix was one hook call and a delete key.
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.
That's the part nobody on the release blog posts tells you: useFormStatus only reports on the form it lives inside — and if it isn't inside one, it doesn't throw. It quietly returns pending: false forever. This post is about why that happens, the three ways I've seen it bite people, and the form pattern that actually works.
What useFormStatus actually is
Before React 19, submitting a form with async work meant one of three things:
- Lift an
isSubmittingstate up and pass it down through every component in the tree (the prop drilling we're trying to escape) - Create a context for a single boolean
- Reach for a form library that manages it for you
React 19 added form actions — you pass a function to the form's action prop instead of wiring up onSubmit and calling preventDefault() by hand. And it added three hooks that hang off that system:
useActionState— runs the action and gives you its return value plus a pending flag in the component that renders the formuseOptimistic— lets you show the result before the server confirms ituseFormStatus— reads the status of the parent form, for descendants that are too deep to receive props
useFormStatus() returns an object with four fields: pending (a boolean), data (the FormData of the last submission), method ('get' or 'post'), and action (a reference to the function passed to the form). For a submit button, 95% of the time you only care about pending:
import { useFormStatus } from 'react-dom'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Save changes'}
</button>
)
}
Notice the import: it's react-dom, not react. Easy to miss, and the linter will happily let you import the wrong one and hit undefined at runtime.
Why it sat there returning false
Here's the entire contract of the hook, straight from the docs: 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.
So the hook reports pending: false in three situations, and none of them warn you.
1. You're using onSubmit, not an action
If your form still does the classic dance — onSubmit={handleSubmit} with e.preventDefault() and a fetch inside — then there is no form action, and useFormStatus 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.
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.
2. The button isn't a descendant of the form
The hook reads context provided by the nearest parent <form>. Any component between the form and the button is fine — that's the whole point. But if the button is a sibling of the form — say your design puts the submit action in a sticky footer bar outside the form element — you get pending: false forever. That's the "prop drilling is annoying but at least it works" tax.
3. You called it in the same component that renders the form
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 useActionState instead, which hands it to you directly.
The pattern that works
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:
// PostForm.jsx
import { useActionState } from 'react'
import { SubmitButton } from './SubmitButton'
async function createPost(formData) {
const title = formData.get('title')
// ...validate, save, return { ok: true } or errors
}
export function PostForm() {
const [state, formAction] = useActionState(createPost, { errors: {} })
return (
<form action={formAction}>
<input name="title" />
<Editor name="body" />
<SubmitButton /> {/* any depth */}
</form>
)
}
// SubmitButton.jsx
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending, data } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? `Saving "${data?.get('title')}"…` : 'Publish post'}
</button>
)
}
Three details that matter:
- The button must be
type="submit". A button without a type defaults tosubmitinside a form, but the moment you move it into a toolbar component, someone gives ittype="button"and the action never fires. datais the FormData of the last submission — handy for showing which title is being saved, but it's stale until the first submit.- The action can be async.
pendingstays true until the promise resolves or rejects.
useFormStatus vs useActionState: pick the right one
People keep asking which one they should use, and the answer is usually "both."
- Use
useActionStatein the component that renders the form. You get the action's return value (validation errors, saved IDs) plusisPending. - Use
useFormStatusin any descendant that needs to react to submission — submit buttons, spinners, "uploading…" labels, disabling an image drop zone while the action runs.
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.
The small print
- Portals work. 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.
- SSR is fine. During server rendering the hook returns the defaults (
pending: false,data: null), then updates on the client. No hydration warnings. - A child form creates a new boundary. 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.)
- It's a status, not a controller. You can't cancel the action or reset the form from here. Reset handling belongs in the action itself.
Where it still falls short
Honest assessment after a month of using it in production:
- No per-field status. 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.
- No progress.
pendingis binary. For a large upload, users see a spinner with no percentage unless you track it yourself with fetch streams or XMLHttpRequest. - No error information. Errors are whatever your action returns — useActionState's state, or a throw that an error boundary catches. The hook tells you nothing about failure.
- Double-submit protection is on you. Disabling the button on
pendingcovers the common case, but Enter-key submissions in some browsers can still double-fire. The robust fix is an idempotency key inside the action.
My advice
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.
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 Snippet Ark 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.