useFormStatus Hook Guide: Form Loading States
The useFormStatus hook reads the submission state of the nearest parent form without requiring a separate useState call. Before React 19, showing a loading spinner or disabling a submit button meant manually tracking submission state with useState and a try/catch block. The useFormStatus hook eliminates this boilerplate entirely, making form feedback feel automatic and consistent across your application.
This hook is designed to work seamlessly with Server Actions—when you submit a form with an async action, useFormStatus automatically reads the pending state from the form's parent. The benefit: you can write loading UI in separate child components that don't need to know about the form's implementation.
How useFormStatus Works
The useFormStatus hook returns an object with submission state. It reads the state from the nearest form ancestor, so you can use it in child components without prop drilling:
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}
export function ProfileForm({ saveProfile }) {
return (
<form action={saveProfile}>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
<SubmitButton />
</form>
);
}
When the form is submitted, pending becomes true immediately. When the Server Action completes (succeeds or fails), pending returns to false. No manual state management required.
Building a Complete Form with Feedback
Here's a realistic signup form that uses useFormStatus to show loading states and field-specific feedback:
import { useFormStatus } from 'react-dom';
import { createUser } from './actions';
function FormInput({ name, label, type = 'text' }) {
const { pending } = useFormStatus();
return (
<div className="form-field">
<label htmlFor={name}>{label}</label>
<input
id={name}
name={name}
type={type}
disabled={pending}
className="input"
/>
</div>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="btn-primary"
>
{pending ? (
<>
<span className="spinner" />
Creating account...
</>
) : (
'Create Account'
)}
</button>
);
}
export function SignupForm() {
async function handleSignup(formData) {
const name = formData.get('name');
const email = formData.get('email');
const password = formData.get('password');
try {
const result = await createUser({ name, email, password });
if (result.ok) {
window.location.href = '/dashboard';
} else {
alert(result.error);
}
} catch (error) {
alert('An unexpected error occurred');
}
}
return (
<form action={handleSignup} className="signup-form">
<h2>Create Your Account</h2>
<FormInput name="name" label="Full Name" />
<FormInput name="email" label="Email Address" type="email" />
<FormInput name="password" label="Password" type="password" />
<SubmitButton />
</form>
);
}
Notice that FormInput and SubmitButton are pure presentational components—they use useFormStatus but don't need to know about the actual form action. This separation makes form components reusable across different forms.
useFormStatus with Multiple Forms
If your page has multiple forms, useFormStatus reads state from the nearest parent form. Each form's submission state is tracked independently:
import { useFormStatus } from 'react-dom';
function ContactForm() {
const { pending } = useFormStatus();
async function handleSubmit(formData) {
const message = formData.get('message');
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ message }),
});
}
return (
<form action={handleSubmit} className="contact-form">
<textarea name="message" placeholder="Your message..." />
<button disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
</form>
);
}
function NewsletterForm() {
const { pending } = useFormStatus();
async function handleSubscribe(formData) {
const email = formData.get('email');
await fetch('/api/subscribe', {
method: 'POST',
body: JSON.stringify({ email }),
});
}
return (
<form action={handleSubscribe} className="newsletter-form">
<input name="email" type="email" placeholder="Email..." />
<button disabled={pending}>
{pending ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
);
}
export function Footer() {
return (
<footer>
<div className="form-section">
<ContactForm />
<NewsletterForm />
</div>
</footer>
);
}
Each form's useFormStatus hook reads from its nearest parent form element. When the contact form submits, only the contact form's button shows "Sending..." The newsletter form's button remains unchanged.
Advanced: Form Status with Error States
You can combine useFormStatus with useFormState to track both pending state and error messages:
import { useFormStatus } from 'react-dom';
import { useFormState } from 'react-dom';
import { updateProfile } from './actions';
function SaveButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className="btn">
{pending ? 'Saving...' : 'Save Changes'}
</button>
);
}
export function ProfileEditor({ user }) {
const [state, formAction] = useFormState(updateProfile, {
errors: null,
success: false,
});
return (
<form action={formAction}>
<div className="form-field">
<label>Name</label>
<input name="name" defaultValue={user.name} />
{state.errors?.name && (
<span className="error">{state.errors.name}</span>
)}
</div>
<div className="form-field">
<label>Email</label>
<input name="email" type="email" defaultValue={user.email} />
{state.errors?.email && (
<span className="error">{state.errors.email}</span>
)}
</div>
<SaveButton />
{state.success && (
<div className="success">Profile updated successfully!</div>
)}
</form>
);
}
Here, useFormStatus handles the loading spinner, and useFormState handles validation errors and success messages. Together they cover the entire form lifecycle.
useFormStatus vs. Manual useState
| Aspect | Manual useState | useFormStatus |
|---|---|---|
| Lines of code | 8–12 (state var, setter, try/catch) | 2–3 (hook call, render) |
| Prop drilling | Yes—pass state down to child components | No—hook reads from form ancestor |
| Error handling | Manual (must track errors separately) | Pairs naturally with useFormState |
| Multiple forms | Must track state for each form | Automatic—each form is independent |
| Mental model | Form and UI state are separate | Form state is implicit in the form element |
Common Patterns and Best Practices
Pattern 1: Disable all inputs while submitting
function FormField({ name, label }) {
const { pending } = useFormStatus();
return (
<input
name={name}
disabled={pending}
/>
);
}
Pattern 2: Show different text based on pending state
function SubmitButton({ idleText, pendingText }) {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? pendingText : idleText}
</button>
);
}
Pattern 3: Add a loading spinner
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending && <Spinner />}
{pending ? 'Processing...' : 'Submit'}
</button>
);
}
Key Takeaways
useFormStatuseliminates manual form state tracking by reading submission state from the nearest parent form.- Use it in child components to build loading indicators and disabled states without prop drilling.
- It works seamlessly with Server Actions and async form handlers.
- Each form's pending state is independent; multiple forms on a page don't interfere.
- Pair it with
useFormStatefor a complete form solution that handles both pending state and errors.
Frequently Asked Questions
Can I use useFormStatus with non-Server-Action forms?
Yes. useFormStatus works with any form that has an action attribute, whether it's a Server Action, a fetch call, or a traditional form submission. It reads from the form element, not the action implementation.
What if useFormStatus is not reading the form state correctly?
Make sure the component using useFormStatus is a child of the form element. The hook searches up the component tree for the nearest form ancestor. If it's outside the form, it won't work. Also check that the form has an action prop (either a function or URL).
Can I reset the pending state manually?
No—useFormStatus is read-only and tracks the form's actual submission state. The state resets automatically when the form submission completes. If you need manual control, use useState instead.
Is useFormStatus compatible with class components?
No. useFormStatus is a hook and only works in function components. Class components must use useState for form state management.
How do I show different loading states for different parts of the form?
Use multiple useFormStatus hooks in different child components. Each one reads from the same form, so they all show the same pending state. If you need different states, you'll need separate forms or custom state management.