React.js

React 19 useFormStatus: Build Better Form Loading States Without Prop Drilling

React 19 useFormStatus hook showing form submission, pending state, reusable submit button, loading feedback, and integration with useActionState.

Learn how React 19's useFormStatus hook helps you track form submission state inside child components, disable buttons, show loading indicators, and build reusable form UI without passing pending state through props.

Introduction

Forms are everywhere in web applications.

We use them for:

  • Login

  • Registration

  • Checkout

  • Profile updates

  • Contact forms

  • Search

  • Password changes

  • Comments

  • Settings

One common requirement is showing the user that a form is currently being submitted.

For example:

Save Profile

becomes:

Saving...

and the button is disabled while the request is running.

Traditionally, we might manage this using:

const [loading, setLoading] = useState(false);

Then pass the loading value into child components:

<SubmitButton loading={loading} />

This works, but it becomes repetitive when form components grow.

React 19 provides a cleaner approach:

useFormStatus

With useFormStatus, a component inside a form can automatically understand whether its parent form is currently being submitted.

No prop drilling is required.


What Is useFormStatus?

Import it from:

import { useFormStatus } from "react-dom";

Notice that it comes from:

react-dom

not:

react

The basic usage is:

const { pending } = useFormStatus();

A submit button can then use:

<button disabled={pending}>
  {pending ? "Saving..." : "Save"}
</button>

That is the main idea.

The button automatically understands the submission state of its parent form.


The Problem Without useFormStatus

Imagine we have this form:

function ProfileForm() {
  const [loading, setLoading] =
    useState(false);

  async function handleSubmit(event) {
    event.preventDefault();

    setLoading(true);

    try {
      await updateProfile();
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />

      <SubmitButton
        loading={loading}
      />
    </form>
  );
}

Then:

function SubmitButton({ loading }) {
  return (
    <button disabled={loading}>
      {loading
        ? "Saving..."
        : "Save Profile"}
    </button>
  );
}

This is completely valid.

But the parent component must:

  1. Create the loading state.

  2. Update the loading state.

  3. Pass it into the button.

  4. Keep the parent and child synchronized.

With React Actions and useFormStatus, the child can read the form state directly.


What We Will Build

We will create a profile form that includes:

  • Name

  • Email

  • Submit button

  • Pending state

  • Disabled button

  • Loading message

  • Form data inspection

  • Validation

  • Success response

The flow will look like:

User
 ↓
Profile Form
 ↓
Submit
 ↓
Form Action
 ↓
useFormStatus
 ↓
Pending UI
 ↓
Action completes
 ↓
Normal UI

Step 1: Create a React 19 Project

Using Vite:

npm create vite@latest use-form-status-demo

Choose:

React
JavaScript

Enter the project:

cd use-form-status-demo

Install dependencies:

npm install

Start the application:

npm run dev

Usually Vite runs at:

http://localhost:5173

Step 2: Confirm React Version

Run:

npm list react react-dom

Make sure you are using React 19 or later.

Your package.json should look similar to:

{
  "dependencies": {
    "react": "^19.x.x",
    "react-dom": "^19.x.x"
  }
}

Step 3: Create a Form Action

Create:

src/actions/updateProfile.js

Add:

export async function updateProfile(
  formData
) {
  const name =
    formData.get("name");

  const email =
    formData.get("email");

  await new Promise((resolve) =>
    setTimeout(resolve, 2000)
  );

  console.log({
    name,
    email,
  });
}

This line:

await new Promise((resolve) =>
  setTimeout(resolve, 2000)
);

simulates a slow API request.

That gives us enough time to see the pending state.


Step 4: Create a Reusable Submit Button

Create:

src/components/SubmitButton.jsx

Add:

import {
  useFormStatus,
} from "react-dom";

export default function SubmitButton() {
  const { pending } =
    useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending
        ? "Saving..."
        : "Save Profile"}
    </button>
  );
}

This is where useFormStatus becomes useful.

The button receives no props.

There is no:

loading={loading}

The button automatically gets the status of its parent form.


Step 5: Create the Profile Form

Create:

src/components/ProfileForm.jsx

Add:

import SubmitButton from "./SubmitButton";

import {
  updateProfile,
} from "../actions/updateProfile";

export default function ProfileForm() {
  return (
    <form action={updateProfile}>
      <div>
        <label htmlFor="name">
          Name
        </label>

        <input
          id="name"
          name="name"
          type="text"
        />
      </div>

      <div>
        <label htmlFor="email">
          Email
        </label>

        <input
          id="email"
          name="email"
          type="email"
        />
      </div>

      <SubmitButton />
    </form>
  );
}

Notice this:

<form action={updateProfile}>

The submit button is a child of this form.

Therefore:

useFormStatus()

inside SubmitButton can access the form submission status.


Step 6: Render the Form

Update:

src/App.jsx

Add:

import ProfileForm
  from "./components/ProfileForm";

function App() {
  return (
    <main>
      <h1>
        Update Profile
      </h1>

      <ProfileForm />
    </main>
  );
}

export default App;

Run:

npm run dev

Open:

http://localhost:5173

Step 7: Test the Pending State

Enter:

Name:
Jalish

and:

Email:
jalish@example.com

Click:

Save Profile

Immediately, the button should change to:

Saving...

It should also become disabled.

After approximately two seconds, it should return to:

Save Profile

The flow is:

Submit
 ↓
Action starts
 ↓
pending = true
 ↓
Button disabled
 ↓
Saving...
 ↓
Action completes
 ↓
pending = false

We did not create any manual loading state.


Understanding pending

The most commonly used property from useFormStatus is:

pending

Example:

const { pending } =
  useFormStatus();

When the form is idle:

pending = false

During submission:

pending = true

This allows us to write:

<button disabled={pending}>

and:

{pending
  ? "Submitting..."
  : "Submit"}

Why Disable the Button?

Consider what happens if the user clicks:

Submit
Submit
Submit
Submit

very quickly.

You may accidentally create multiple API requests.

For example:

POST /api/orders
POST /api/orders
POST /api/orders

This can cause problems such as:

  • Duplicate orders

  • Duplicate comments

  • Duplicate payments

  • Duplicate emails

Disabling the button helps prevent accidental repeated submissions.

<button
  disabled={pending}
>

Step 8: Create a Loading Spinner

Instead of showing only text, we can create a visual loading state.

Update:

export default function SubmitButton() {
  const { pending } =
    useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending && (
        <span>
          ⏳
        </span>
      )}

      {pending
        ? "Saving..."
        : "Save Profile"}
    </button>
  );
}

Now users receive clearer feedback.


Step 9: Access Other Form Status Information

useFormStatus can provide more than just:

pending

For example:

const {
  pending,
  data,
  method,
  action,
} = useFormStatus();

These values represent information about the parent form submission.


data

The data property contains the submitted FormData.

Example:

const {
  pending,
  data,
} = useFormStatus();

We could inspect:

data?.get("name")

For example:

function SubmitButton() {
  const {
    pending,
    data,
  } = useFormStatus();

  const name =
    data?.get("name");

  return (
    <button
      disabled={pending}
    >
      {pending
        ? `Saving ${name ?? ""}...`
        : "Save Profile"}
    </button>
  );
}

If the user enters:

Jalish

the button might show:

Saving Jalish...

method

You can access the submission method:

const {
  method,
} = useFormStatus();

For example:

console.log(method);

This tells you how the form is being submitted.


action

You can also access the form Action:

const {
  action,
} = useFormStatus();

For most everyday form components, you will mainly use:

pending

But knowing the additional properties can be useful when building advanced reusable UI.


Important Rule: Component Must Be Inside the Form

This is one of the most important concepts.

This will work:

<form action={saveProfile}>
  <input name="name" />

  <SubmitButton />
</form>

because:

SubmitButton

is inside the form.

But this will not behave as expected:

<>
  <SubmitButton />

  <form action={saveProfile}>
    <input name="name" />
  </form>
</>

The button is outside the form.

useFormStatus reads the status of its parent form.


Another Common Mistake

You may try this:

function ProfileForm() {
  const { pending } =
    useFormStatus();

  return (
    <form action={updateProfile}>
      ...
    </form>
  );
}

This is not the ideal structure.

The component calling useFormStatus needs to be rendered inside the form context.

A better structure is:

ProfileForm
  ↓
<form>
  ↓
SubmitButton
  ↓
useFormStatus

Step 10: Add Form Validation

Now let's make the Action more realistic.

Update:

src/actions/updateProfile.js

with:

export async function updateProfile(
  formData
) {
  const name =
    formData.get("name");

  const email =
    formData.get("email");

  if (!name?.trim()) {
    throw new Error(
      "Name is required"
    );
  }

  if (!email?.trim()) {
    throw new Error(
      "Email is required"
    );
  }

  await new Promise((resolve) =>
    setTimeout(resolve, 2000)
  );

  console.log(
    "Profile updated:",
    {
      name,
      email,
    }
  );
}

Now the Action represents a real mutation more closely.


Combining useFormStatus with useActionState

This is where React 19 form handling becomes especially useful.

useFormStatus handles:

Form submission status

while:

useActionState

can handle:

Action result
Validation errors
Success messages

For example:

const [
  state,
  formAction,
  isPending,
] = useActionState(
  updateProfile,
  initialState
);

Then:

<form action={formAction}>
  ...
  <SubmitButton />
</form>

Inside:

SubmitButton

we can still use:

useFormStatus()

Why Use Both?

Think of the responsibilities like this:

useActionState
    ↓
What happened?

Success?
Error?
Validation result?

useFormStatus
    ↓
What is happening right now?

Submitting?
Idle?

This separation makes reusable form components easier to design.


Complete Example

Let's combine both hooks.

Profile Action

export async function updateProfile(
  previousState,
  formData
) {
  const name =
    formData.get("name");

  const email =
    formData.get("email");

  if (!name?.trim()) {
    return {
      success: false,
      message:
        "Name is required.",
    };
  }

  if (!email?.trim()) {
    return {
      success: false,
      message:
        "Email is required.",
    };
  }

  await new Promise((resolve) =>
    setTimeout(resolve, 2000)
  );

  return {
    success: true,
    message:
      "Profile updated successfully.",
  };
}

Profile Form

import {
  useActionState,
} from "react";

import SubmitButton
  from "./SubmitButton";

import {
  updateProfile,
} from "../actions/updateProfile";

const initialState = {
  success: false,
  message: "",
};

export default function ProfileForm() {
  const [
    state,
    formAction,
  ] = useActionState(
    updateProfile,
    initialState
  );

  return (
    <form action={formAction}>
      <div>
        <label>
          Name
        </label>

        <input
          name="name"
        />
      </div>

      <div>
        <label>
          Email
        </label>

        <input
          name="email"
          type="email"
        />
      </div>

      <SubmitButton />

      {state.message && (
        <p>
          {state.message}
        </p>
      )}
    </form>
  );
}

Submit Button

import {
  useFormStatus,
} from "react-dom";

export default function SubmitButton() {
  const {
    pending,
  } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending
        ? "Updating..."
        : "Update Profile"}
    </button>
  );
}

Now responsibilities are clearly separated.


How the Complete Flow Works

The complete request lifecycle is:

User
 ↓
ProfileForm
 ↓
SubmitButton clicked
 ↓
Form Action triggered
 ↓
useFormStatus
 ↓
pending = true
 ↓
Button shows "Updating..."
 ↓
Action executes
 ↓
useActionState receives result
 ↓
Success/Error displayed
 ↓
pending = false

This is a clean React 19 form architecture.


Practical Use Case 1: Login Form

function LoginButton() {
  const {
    pending,
  } = useFormStatus();

  return (
    <button
      disabled={pending}
    >
      {pending
        ? "Signing in..."
        : "Sign In"}
    </button>
  );
}

The user immediately understands that authentication is processing.


Practical Use Case 2: Registration

Create Account

becomes:

Creating Account...

during submission.

This prevents repeated registration requests.


Practical Use Case 3: Checkout

A checkout button might change:

Place Order

to:

Processing Order...

while payment and order creation are being processed.

This is much clearer for users than leaving the button unchanged.


Practical Use Case 4: Delete Confirmation

Imagine:

Delete Account

After clicking:

Deleting...

The button can also become disabled.

This prevents repeated destructive actions.


Practical Use Case 5: Comment Submission

A comment form can show:

Post Comment

and then:

Posting...

during submission.

This gives immediate UI feedback.


Practical Use Case 6: File Upload Form

For file uploads:

Upload Image

can become:

Uploading...

However, remember that useFormStatus tells you whether the form is pending.

It does not automatically provide detailed upload percentage such as:

37%
64%
92%

For exact upload progress, you may need additional browser APIs or an HTTP client that exposes upload progress.


useFormStatus vs useState

You might ask:

Should I stop using useState for loading states?

No.

useState is still useful.

Use:

useState

for local component state such as:

  • Modal visibility

  • Dropdown state

  • Tab selection

  • Theme

  • Search input

  • UI toggles

Use:

useFormStatus

when a child component needs the status of a parent form submission.


useFormStatus vs useActionState

These hooks complement each other.

useFormStatus

Best for:

Pending state
Reusable submit buttons
Form loading indicators
Form submission metadata

useActionState

Best for:

Action result
Validation errors
Success state
Action-driven state

Together:

useActionState
+
useFormStatus

provide a very clean approach to forms.


useFormStatus vs useTransition

useTransition is designed for transitions and non-blocking UI updates.

Example:

const [
  isPending,
  startTransition,
] = useTransition();

useFormStatus is specifically connected to:

Form submission

So if your problem is:

Is this form currently submitting?

useFormStatus is usually the more direct abstraction.


A Reusable Button Component

One of the best use cases is creating a reusable button.

import {
  useFormStatus,
} from "react-dom";

export default function FormButton({
  idleText,
  pendingText,
}) {
  const {
    pending,
  } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
    >
      {pending
        ? pendingText
        : idleText}
    </button>
  );
}

Now use it anywhere.

Login:

<FormButton
  idleText="Sign In"
  pendingText="Signing in..."
/>

Registration:

<FormButton
  idleText="Create Account"
  pendingText="Creating..."
/>

Profile:

<FormButton
  idleText="Save Profile"
  pendingText="Saving..."
/>

Checkout:

<FormButton
  idleText="Place Order"
  pendingText="Processing..."
/>

This is a practical reason useFormStatus is useful in real projects.


Testing Checklist

After building the example, test the following cases.

Test 1 — Normal State

Before submission:

Save Profile

The button should be enabled.


Test 2 — Pending State

Click:

Save Profile

Expected:

Saving...

The button should be disabled.


Test 3 — Double Click

Try clicking rapidly.

Expected:

Only one active submission

because the button becomes disabled.


Test 4 — Successful Request

Enter valid information.

Expected:

Profile updated successfully.

Test 5 — Validation Error

Leave the name empty.

Expected:

Name is required.

Test 6 — Slow API

Increase the simulated delay:

setTimeout(resolve, 5000)

The pending UI should remain visible for around five seconds.


Common Mistakes

Mistake 1: Importing From the Wrong Package

Wrong:

import {
  useFormStatus,
} from "react";

Correct:

import {
  useFormStatus,
} from "react-dom";

Mistake 2: Using It Outside the Form

Wrong structure:

<SubmitButton />

<form>
  ...
</form>

Better:

<form>
  ...
  <SubmitButton />
</form>

Mistake 3: Manually Passing Pending State Anyway

You do not need:

<SubmitButton
  isPending={isPending}
/>

when the button can directly use:

useFormStatus()

Before React 19 Style

A common pattern was:

Form Component
 ↓
useState
 ↓
loading
 ↓
Pass loading through props
 ↓
Submit Button

For example:

<SubmitButton
  loading={loading}
/>

With useFormStatus:

Form
 ↓
Submit Button
 ↓
useFormStatus

The button reads the status directly.


Final Architecture

A clean React 19 form can look like:

ProfileForm
 │
 ├── useActionState
 │       ↓
 │   Result State
 │
 └── <form>
        │
        ├── Inputs
        │
        └── SubmitButton
                ↓
           useFormStatus
                ↓
             pending

Responsibilities stay separated.

useActionState
→ Action result

useFormStatus
→ Form status

Conclusion

React 19's useFormStatus is a small hook, but it solves a very common UI problem.

Instead of manually creating:

const [loading, setLoading] =
  useState(false);

and passing that value through props, a child form component can simply use:

const {
  pending,
} = useFormStatus();

This is especially useful for reusable:

  • Submit buttons

  • Loading indicators

  • Login forms

  • Registration forms

  • Checkout forms

  • Profile forms

  • Delete actions

The key concept is simple:

Parent Form
    ↓
Child Component
    ↓
useFormStatus
    ↓
Current Form Status

When combined with:

useActionState

React 19 gives developers a much cleaner way to manage form actions, pending states, validation, and UI feedback.

If you are learning React 19, build this example and test it with:

Slow request
Successful request
Validation error
Rapid clicks
Reusable submit buttons

Once you do that, the purpose of useFormStatus becomes very easy to understand.