Forms need feedback. When a user clicks submit, they need to know something is happening. React 19's useFormStatus hook provides the pending state of a form submission, making it easy to disable buttons, show spinners, and prevent double submissions.
What useFormStatus provides
The hook returns an object with submission details:
| Property | Type | Description |
|---|---|---|
pending |
boolean | True while form is submitting |
data |
FormData | null | Form data being submitted |
method |
string | HTTP method (get, post, etc.) |
action |
function | The action being invoked |
Most commonly, you use pending to control UI state.
Basic usage
Create a submit button component that uses the hook:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={pending ? 'opacity-50 cursor-not-allowed' : ''}
>
{pending ? 'Submitting...' : children}
</button>
);
}
Use it inside a form:
import { submitForm } from './actions';
import { SubmitButton } from './SubmitButton';
export function ContactForm() {
return (
<form action={submitForm}>
<input name="email" type="email" required />
<input name="message" required />
<SubmitButton>Send Message</SubmitButton>
</form>
);
}
When the form submits, the button automatically disables and shows "Submitting...".
The component boundary rule
useFormStatus must be called inside a child component of the form. It won't work at the form level:
// WRONG: useFormStatus at form level
function ContactForm() {
const { pending } = useFormStatus(); // Won't work!
return (
<form action={submitForm}>
<button disabled={pending}>Submit</button>
</form>
);
}
// CORRECT: useFormStatus in child component
function ContactForm() {
return (
<form action={submitForm}>
<SubmitButton>Submit</SubmitButton> {/* useFormStatus inside */}
</form>
);
}
This design allows multiple forms on a page—each component only sees its parent form's status.
With Server Actions
useFormStatus integrates seamlessly with Next.js Server Actions:
// actions.ts
'use server';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.insert(posts).values({ title, content });
revalidatePath('/posts');
}
// page.tsx
import { createPost } from './actions';
import { SubmitButton } from '@/components/SubmitButton';
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<SubmitButton>Create Post</SubmitButton>
</form>
);
}
Loading indicators
Show different loading states:
'use client';
import { useFormStatus } from 'react-dom';
import { Loader2 } from 'lucide-react';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="flex items-center gap-2"
>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{pending ? 'Saving...' : children}
</button>
);
}
Form-wide loading states
Disable all inputs during submission:
'use client';
import { useFormStatus } from 'react-dom';
export function FormFields() {
const { pending } = useFormStatus();
return (
<fieldset disabled={pending} className={pending ? 'opacity-50' : ''}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
</fieldset>
);
}
Accessing submitted data
Use data to show what's being submitted:
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitStatus() {
const { pending, data } = useFormStatus();
if (!pending) return null;
const email = data?.get('email');
return (
<div className="text-sm text-gray-500">
Submitting for {email}...
</div>
);
}
Combining with useActionState
useFormStatus provides pending state. useActionState manages the action result:
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { submitContact } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
);
}
export function ContactForm() {
const [state, formAction] = useActionState(submitContact, {
error: null,
success: false,
});
return (
<form action={formAction}>
<input name="email" type="email" required />
<textarea name="message" required />
{state.error && <p className="text-red-500">{state.error}</p>}
{state.success && <p className="text-green-500">Sent!</p>}
<SubmitButton />
</form>
);
}
Multiple submit buttons
Handle forms with multiple actions:
'use client';
import { useFormStatus } from 'react-dom';
export function ActionButtons() {
const { pending, action } = useFormStatus();
return (
<div className="flex gap-2">
<button
type="submit"
formAction={saveDraft}
disabled={pending}
>
{pending && action === saveDraft ? 'Saving...' : 'Save Draft'}
</button>
<button
type="submit"
formAction={publish}
disabled={pending}
>
{pending && action === publish ? 'Publishing...' : 'Publish'}
</button>
</div>
);
}
Progressive enhancement
Forms work without JavaScript. useFormStatus enhances the experience:
// Server Component (no JavaScript needed)
export function BasicForm() {
return (
<form action={submitForm}>
<input name="email" required />
<button type="submit">Submit</button>
</form>
);
}
// Enhanced with client component
export function EnhancedForm() {
return (
<form action={submitForm}>
<input name="email" required />
<SubmitButton>Submit</SubmitButton> {/* Enhanced UX */}
</form>
);
}
Without JavaScript, the form still submits. With JavaScript, users get loading feedback.
Common patterns
| Pattern | Implementation |
|---|---|
| Disable on submit | disabled={pending} |
| Show spinner | {pending && <Spinner />} |
| Change button text | {pending ? 'Saving...' : 'Save'} |
| Disable all fields | <fieldset disabled={pending}> |
| Prevent double submit | Button disabled + spinner |
| Show submitted data | Access data.get('field') |
Testing
Test both pending and completed states:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('shows loading state on submit', async () => {
render(<ContactForm />);
await userEvent.type(screen.getByRole('textbox', { name: /email/i }), 'test@example.com');
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText(/submitting/i)).toBeInTheDocument();
});
Summary
useFormStatus provides the pending state of form submissions. Use it in child components to disable buttons, show spinners, and prevent double submissions. It works with Server Actions and client handlers alike. Combine with useActionState for complete form state management. The pattern improves UX while keeping forms progressively enhanced.
