DEV Community

Cover image for React 19's useFormStatus Fixed My Prop Drilling. Then It Sat There Returning False
Shubhra Pokhariya
Shubhra Pokhariya

Posted on • Originally published at shubhra.dev

React 19's useFormStatus Fixed My Prop Drilling. Then It Sat There Returning False

Part 1 was about waiting well. Part 2 was about not waiting at all. This one is about a value I needed in a component that never created it, and about the ten minutes I lost putting the hook in the one place it can't work.

If you've read Part 1 on useActionState and Part 2 on useOptimistic, both of those hooks answer a question from inside the component that called them. That was never a problem for a two-field newsletter form. It became one the day I split an order form into separate pieces, payment details in one file, the place-order button in another, and both of them needed to know the same thing: is this thing submitting right now.

The setup that made prop drilling look reasonable

Here's roughly what the form looked like before I touched useFormStatus at all.

import { useActionState } from "react";

function PaymentFields({ disabled }) {
  return (
    <fieldset disabled={disabled}>
      <input name="cardNumber" placeholder="Card number" />
      <input name="cardExpiry" placeholder="MM/YY" />
    </fieldset>
  );
}

function PlaceOrderButton({ pending }) {
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Placing order..." : "Place order"}
    </button>
  );
}

function OrderForm() {
  const [state, formAction, isPending] = useActionState(placeOrder, {
    message: "",
  });

  return (
    <form action={formAction}>
      <PaymentFields disabled={isPending} />
      <PlaceOrderButton pending={isPending} />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Nothing about this is broken. It works. But isPending lives in OrderForm, and both children only exist to receive it and hand it to a disabled attribute. Add a shipping section, an order-summary sidebar, a coupon field that should lock during submission, and every one of them needs the same prop threaded through, whether or not anything else about them changes. I'd read that useFormStatus was built to skip exactly this, so I went looking for where to put it.

Where I put it first, and why it did nothing

My first instinct was to call the hook right where isPending used to live, in OrderForm itself, treating it like a drop-in replacement.

import { useFormStatus } from "react-dom";

function OrderForm() {
  const { pending } = useFormStatus(); // stays false forever

  return (
    <form action={placeOrder}>
      <PaymentFields disabled={pending} />
      <PlaceOrderButton pending={pending} />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

I clicked "Place order." Nothing disabled. No console warning, no red error overlay, just a button sitting there fully clickable while the network tab showed a request in flight. My first guess was that placeOrder wasn't actually async, or that I'd broken something in the action itself. I spent a few minutes staring at the action function before I even looked back at the hook.

The actual answer is simpler and, once you see it, a little obvious. useFormStatus doesn't read state that OrderForm owns. It reads the status of the parent <form>, and at the moment that hook call runs, OrderForm is still deciding what to render. The <form> tag doesn't exist as anyone's parent yet, it's the thing being built. There's no ancestor for the hook to find, so it falls back to its default, unpending shape, and it stays that way no matter how long the real submission takes.

The rule underneath that: the component calling useFormStatus has to be a descendant of the form it wants to read, never the component rendering the form itself. Fixing it meant pulling the hook out of OrderForm and putting it somewhere further down the tree.

import { useFormStatus } from "react-dom";

function PaymentFields() {
  const { pending } = useFormStatus();
  return (
    <fieldset disabled={pending}>
      <input name="cardNumber" placeholder="Card number" />
      <input name="cardExpiry" placeholder="MM/YY" />
    </fieldset>
  );
}

function PlaceOrderButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Placing order..." : "Place order"}
    </button>
  );
}

function OrderForm() {
  return (
    <form action={placeOrder}>
      <PaymentFields />
      <PlaceOrderButton />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Same markup, same behavior at a glance, and suddenly pending flips correctly. Neither child receives anything from OrderForm anymore. Both just ask their parent form what's going on and get told the truth.

One detail that's easy to miss on the way there: this hook comes from react-dom, not react. useActionState and useOptimistic both live in react, so if you're moving through this series in order, typing import { useFormStatus } from "react" is a genuinely easy slip, and it fails in a way that doesn't tell you what went wrong either.

The part I didn't expect: it's watching more than a boolean

Once pending worked, I assumed that was the whole hook. It isn't. Calling it gives you an object with three more properties riding along, and I only found a real use for them once I stopped treating useFormStatus as a fancy boolean.

const { pending, data, method, action } = useFormStatus();
Enter fullscreen mode Exit fullscreen mode

data is the FormData currently being submitted, available only while something is in flight. I used it to make the button say something more specific than a generic spinner label:

function PlaceOrderButton() {
  const { pending, data } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? `Placing order for ${data.get("email")}...` : "Place order"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

data is null the rest of the time, so that .get("email") call only runs safely because it's tucked inside the pending branch of the ternary. Pull it out and call it unconditionally, and the component throws the first time it renders idle.

method is where I got tripped up briefly, because I assumed it would mirror whatever method attribute I'd have put on the form. It doesn't work that way once you're using a function-based action. Any form using the action prop with a function, which is the entire reason this hook has something to track in the first place, submits as 'post' regardless of anything else you configure. You'd only see 'get' here on a form using a plain URL string as its action, or no action prop at all, and the docs only commit to one thing about that case: action itself comes back null. They stay quiet on pending and data, and since only function-based actions actually run through React's Transition tracking, that's not a combination I'd build anything around.

action is a reference back to whatever function you passed to the form's action prop. It's null for a form with no function-based action, and it's also null if there's no parent form at all, which means a falsy value here doesn't automatically tell you which of those two is true. I hit this once trying to conditionally render something based on whether action existed, and had to remind myself both cases collapse to the same falsy check.

The reused-component trick I didn't plan for, and the one I got burned by

The nicest surprise came later, almost by accident. PlaceOrderButton wasn't just used on the checkout page. It got reused, unmodified, on a "save draft order" form elsewhere in the app. I expected to need some kind of context or ID to tell it which form it belonged to. I didn't. useFormStatus resolves the nearest ancestor form independently everywhere it's rendered, so the same component read the correct pending state on both forms without a single line of extra wiring. That's not an edge case worth working around, it's a real reason to build small, reusable status-aware components in the first place.

The edge case that did surprise me went the other direction. Somewhere in an admin panel, a designer had wired a button outside its form using the HTML form="orderForm" attribute instead of physically nesting it. Pure HTML, no React opinion needed, and it submits the form fine. useFormStatus inside that button read as permanently not-pending, even while the form it was technically attached to was mid-submission. The hook walks the component tree, not the DOM-level form attribute association, so as far as it's concerned, that button was never a child of anything. Worth knowing before it surprises you in a codebase that uses this attribute anywhere.

Where I stopped reaching for it

Not every form needed this. On a two-field signup form with a single button in the same component, useActionState's own isPending, read directly where the hook was called, did the job without adding a second hook and a second rule to remember. useFormStatus earns its place once a form's parts genuinely live in separate components, not before.

And it only ever tells you about a submission already underway. useFormStatus isn't a validation API, it tells you what the form is doing once submission has started, not whether that submission should have been allowed to start in the first place. That decision still belongs upstream, in the action function or wherever else you're handling the submit.

Where useActionState, useOptimistic, and useFormStatus actually sit

Three parts into this series and the boundaries finally made sense to me as one picture instead of three separate hooks to memorize. useActionState owns a result and reacts to it, from wherever it was called. useOptimistic shows the finished UI before the server has actually agreed to it. useFormStatus answers one question, is this form pending, from any component underneath it, with nothing passed down to make that possible.

The distinction that finally made it stick for me: useActionState's isPending follows an Action, specifically the one dispatched through that hook call. useFormStatus's pending follows a form, whichever <form> happens to be its nearest parent, regardless of which component or which action put it there. That's why the same PlaceOrderButton worked unmodified on two different forms with two different actions later on. It was never watching an action. It was watching whatever form it happened to be sitting inside.

They stack without fighting each other. The order form I started with ended up using two of the three, useActionState in the parent owning the result, useFormStatus in every child section reading the same pending state independently.

I wrote the full breakdown on my site, including the complete four-section checkout example combining both hooks, the exact difference between this and useActionState's own isPending, and a side-by-side table for picking the right one: React 19 useFormStatus Explained.

If you want to test what you just learned, I also put together a short React 19 useFormStatus Quiz.

If you've built a form where the submit button lives three components away from the one managing the submission, it's worth checking where you actually called the hook before you assume something's broken. I know that's exactly where I lost ten minutes I didn't need to.

Top comments (0)