skip to content

A React 19 component renders <form action={submit}> and calls useFormStatus() in that same component to disable its button, but pending is always false. Why does that happen, and where must useFormStatus() be called instead?

level: middleimportance: must knowfreq 52%

answer

  1. it reads upward, not downward
  2. the form must be an ancestor
  3. same component means no ancestor form
  4. extract the button into a child
  5. and it lives in react-dom

basics

~20 s

useFormStatus reports on the nearest ancestor form, so a component that renders the form is not inside it and always sees pending false. Call the hook in a child component rendered within the form. It is exported by react-dom.

solid answer

~50 s

`useFormStatus()` reads the submission status of the nearest `<form>` **above** the component in the tree. The component that renders the `<form>` element is that form's parent, not its descendant, so there is no ancestor form to report on and `pending` stays `false` forever. The fix is structural, not a flag: extract the button into its own component and render it inside the form, then call `useFormStatus()` there. That is exactly the design intent — a reusable `SubmitButton` can disable itself in any form without a single prop being threaded through. Two more details worth saying out loud: the hook takes no arguments and is imported from `react-dom`, not `react`; and it returns `{ pending, data, method, action }`, where `data` is the `FormData` currently being submitted and `action` is whatever was passed to the form's `action` prop.

code

jsx · 19 lines
jsx
import { useFormStatus } from 'react-dom';

function SubmitButton({ children }) {
  const { pending, data } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? `Saving ${data?.get('title') ?? ''}…` : children}
    </button>
  );
}

export function NoteForm({ save }) {
  return (
    <form action={save}>
      <input name="title" />
      <SubmitButton>Save note</SubmitButton>
    </form>
  );
}

go deeper

for a junior

Remember the shape of the fix: put the button in its own component inside the <form> and call useFormStatus() there, importing it from react-dom.

for a middle

Explain that the status flows down like context from the <form> element, so the component rendering the form is its parent and has no ancestor form to read. Name the returned fields: pending, data, method, action.

for a senior

Demonstrate the design payoff: one shared SubmitButton gives every form correct disabled and busy states with zero prop threading, and know the hook sees only form submissions, not async work kicked off from an onClick.

for a principal

Be ready to decide when submission state should be ambient like this versus explicitly passed: ambient reads keep leaf components reusable but hide a dependency on being rendered inside a form, which is a contract your component library has to document and lint for.

## The rule in one line `useFormStatus()` gives you the status of the closest `<form>` **ancestor**. Not the form you render — the form you are inside. ```jsx // Broken: this component renders the form, so it is not inside it. function Broken() { const { pending } = useFormStatus(); // always { pending: false, ... } return ( <form action={submit}> <input name="q" /> <button disabled={pending}>Search</button> </form> ); } ``` ```jsx // Working: the hook is called in a descendant of the form. import { useFormStatus } from 'react-dom'; function SubmitButton({ children }) { const { pending } = useFormStatus(); return <button type="submit" disabled={pending}>{pending ? 'Saving…' : children}</button>; } function Working() { return ( <form action={submit}> <input name="q" /> <SubmitButton>Search</SubmitButton> </form> ); } ``` ## Why it works that way The status is delivered like context: the `<form>` element itself makes its submission state available to the subtree React renders beneath it. A component reads context from its ancestors, and JSX you return has not been mounted yet when your component body runs — so a component reading the status of a form it is about to return would be asking about something that does not exist yet in the tree above it. Extracting the reader into a child is not a workaround; it is the only arrangement in which the question has an answer. Note the boundary carefully: it is about **components**, not JSX nesting. Writing the button's JSX inside the `<form>` element of the same component does not help, because the hook call still sits in the component that owns the form. The hook must be called in a *different component* that React renders as a descendant. ## What it returns `useFormStatus()` takes no arguments and returns an object with four fields: - **`pending`** — `true` while the form's action is running, `false` otherwise. This is the field almost every use case wants. - **`data`** — the `FormData` being submitted, or `null` when idle. Useful for optimistically echoing a submitted value: `data?.get('title')`. - **`method`** — the HTTP method the submission is using, `'get'` or `'post'`. - **`action`** — the value that was passed to the form's `action` prop: the function reference, or the URL string. ```jsx function PendingLabel() { const { pending, data } = useFormStatus(); if (!pending) return null; return <p>Saving “{data?.get('title')}”…</p>; } ``` ## The import surprises people It comes from `react-dom`, not `react`: ```js import { useFormStatus } from 'react-dom'; ``` That placement is deliberate — it is about the DOM `<form>` element, which is a renderer concern, not a core React one. `useActionState` and `useOptimistic` live in `react`; only this one is in `react-dom`. Interviewers ask because it is the kind of thing you only remember if you have actually wired it up. ## Practical consequences **A shared button component pays for itself.** Once `SubmitButton` reads the status itself, every form in the app gets correct disabling and a spinner with no props at all. Compare with threading an `isPending` boolean down two or three component layers, where each new form must remember to pass it. **One form, one status.** Each `<form>` provides its own status, so nested layouts with several forms each report independently, and a descendant sees only its nearest one. **It only sees form submissions.** If some action is started outside a form submit — a button `onClick` that calls an async function directly — no form submission is in flight and `pending` stays `false`. The status is scoped to what the `<form>` did, not to "is something async happening". **Don't fake it.** The temptation when you hit the always-false case is to add a `useState` boolean and set it manually, which re-introduces exactly the bug class the hook removes: a flag that survives an early return, a thrown error, or an interleaved second submission. Extract the child instead. ## Quick diagnosis checklist When `pending` never turns true, check in order: is the hook called in a component that is a **descendant** of the form; is it imported from `react-dom`; and does the form actually have a **function** on its `action` prop rather than a URL string or a bare `onSubmit` handler? All three must hold.

  • Besides pending, what else does useFormStatus() return and what would you use it for?
    It returns `data`, `method` and `action` as well. `data` is the `FormData` currently being submitted (null when idle), which lets a pending indicator echo the submitted value — `data?.get('title')` — without lifting state. `method` is `'get'` or `'post'`, and `action` is the function or URL string given to the form's action prop, useful in a generic status component that behaves differently per target.
  • Why is useFormStatus imported from react-dom while useActionState comes from react?
    Because it is tied to the DOM `<form>` element specifically, which is the DOM renderer's concern rather than core React's. `useActionState` and `useOptimistic` describe state and transitions and work independently of any host element, so they sit in `react`. It is a packaging detail with no runtime subtlety, but it is a frequent import mistake.
  • Does moving the button's JSX inside the <form> tags fix the always-false pending, if the hook call stays in the same component?
    No. The boundary is the component, not the JSX nesting. The hook runs during the render of the component that owns the form, which sits above the form in the tree, so there is still no ancestor form to report on. Only calling the hook inside a separate component that React renders beneath the form works.

saying these in an interview costs you the question

  • Thinks pending reflects any form rendered by the component
  • Imports useFormStatus from react
  • Says nesting the JSX inside the form tags is enough
  • Passes an argument such as the form ref to useFormStatus
  • Falls back to a manual isSubmitting useState instead of extracting a child

context