React 19 useFormStatus: Build Better Form Loading States Without Prop Drilling
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 Profilebecomes:
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:
useFormStatusWith 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-domnot:
reactThe 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:
Create the loading state.
Update the loading state.
Pass it into the button.
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 UIStep 1: Create a React 19 Project
Using Vite:
npm create vite@latest use-form-status-demoChoose:
React
JavaScriptEnter the project:
cd use-form-status-demoInstall dependencies:
npm installStart the application:
npm run devUsually Vite runs at:
http://localhost:5173Step 2: Confirm React Version
Run:
npm list react react-domMake 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.jsAdd:
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.jsxAdd:
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.jsxAdd:
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.jsxAdd:
import ProfileForm
from "./components/ProfileForm";
function App() {
return (
<main>
<h1>
Update Profile
</h1>
<ProfileForm />
</main>
);
}
export default App;Run:
npm run devOpen:
http://localhost:5173Step 7: Test the Pending State
Enter:
Name:
Jalishand:
Email:
jalish@example.comClick:
Save ProfileImmediately, the button should change to:
Saving...It should also become disabled.
After approximately two seconds, it should return to:
Save ProfileThe flow is:
Submit
↓
Action starts
↓
pending = true
↓
Button disabled
↓
Saving...
↓
Action completes
↓
pending = falseWe did not create any manual loading state.
Understanding pending
The most commonly used property from useFormStatus is:
pendingExample:
const { pending } =
useFormStatus();When the form is idle:
pending = falseDuring submission:
pending = trueThis 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
Submitvery quickly.
You may accidentally create multiple API requests.
For example:
POST /api/orders
POST /api/orders
POST /api/ordersThis 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:
pendingFor 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:
Jalishthe 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:
pendingBut 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:
SubmitButtonis 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
↓
useFormStatusStep 10: Add Form Validation
Now let's make the Action more realistic.
Update:
src/actions/updateProfile.jswith:
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 statuswhile:
useActionStatecan handle:
Action result
Validation errors
Success messagesFor example:
const [
state,
formAction,
isPending,
] = useActionState(
updateProfile,
initialState
);Then:
<form action={formAction}>
...
<SubmitButton />
</form>Inside:
SubmitButtonwe 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 = falseThis 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 Accountbecomes:
Creating Account...during submission.
This prevents repeated registration requests.
Practical Use Case 3: Checkout
A checkout button might change:
Place Orderto:
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 AccountAfter 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 Commentand then:
Posting...during submission.
This gives immediate UI feedback.
Practical Use Case 6: File Upload Form
For file uploads:
Upload Imagecan 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
useStatefor loading states?
No.
useState is still useful.
Use:
useStatefor local component state such as:
Modal visibility
Dropdown state
Tab selection
Theme
Search input
UI toggles
Use:
useFormStatuswhen 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 metadatauseActionState
Best for:
Action result
Validation errors
Success state
Action-driven stateTogether:
useActionState
+
useFormStatusprovide 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 submissionSo 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 ProfileThe button should be enabled.
Test 2 — Pending State
Click:
Save ProfileExpected:
Saving...The button should be disabled.
Test 3 — Double Click
Try clicking rapidly.
Expected:
Only one active submissionbecause 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 ButtonFor example:
<SubmitButton
loading={loading}
/>With useFormStatus:
Form
↓
Submit Button
↓
useFormStatusThe button reads the status directly.
Final Architecture
A clean React 19 form can look like:
ProfileForm
│
├── useActionState
│ ↓
│ Result State
│
└── <form>
│
├── Inputs
│
└── SubmitButton
↓
useFormStatus
↓
pendingResponsibilities stay separated.
useActionState
→ Action result
useFormStatus
→ Form statusConclusion
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 StatusWhen combined with:
useActionStateReact 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 buttonsOnce you do that, the purpose of useFormStatus becomes very easy to understand.