React 19 introduced two hooks specifically for server action forms: useFormState and useFormStatus. Together they solve the main UX problems with form submissions — showing loading states, displaying server errors, and updating UI based on server responses — without requiring client-side state management for the form itself.
Here's how both hooks work in Next.js App Router and the patterns that produce the cleanest implementations, including the approach used for the generation form at AI presentation images.
The Problem These Hooks Solve
Before React 19, handling a Server Action form required:
- A Server Action for the form submission
- Client-side state (
useState) for pending state - Client-side state for error display
-
useTransitionorisPendingfor loading state The new hooks reduce this significantly.
useFormStatus — Pending State Inside Forms
useFormStatus gives any component inside a form access to the form's submission status. The key behavior: it reads from the nearest parent <form>, not from props.
// components/SubmitButton.jsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={`px-6 py-2 rounded-full font-medium transition-all
${pending
? 'bg-neutral-400 cursor-not-allowed'
: 'bg-orange-500 hover:bg-orange-600'
}`}
>
{pending ? 'Submitting...' : children}
</button>
);
}
This SubmitButton component automatically shows a pending state when the parent form is submitting — no prop drilling, no manual state required.
// Any form that uses this button
export function ContactForm() {
return (
<form action={submitContactForm}>
<input name="email" type="email" required />
<textarea name="message" required />
<SubmitButton>Send Message</SubmitButton>
{/* SubmitButton knows the form is pending automatically */}
</form>
);
}
useFormStatus also exposes data (the FormData being submitted), method, and action — useful for more advanced patterns.
useFormState — Server Response in the Client
useFormState wraps a Server Action and gives you access to its return value in the client component. This is how you display server-side validation errors and success states.
// app/actions/contact.ts
'use server';
type FormState = {
success: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export async function submitContactForm(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const email = formData.get('email') as string;
const message = formData.get('message') as string;
if (!email || !email.includes('@')) {
return {
success: false,
fieldErrors: { email: 'Please enter a valid email address' }
};
}
if (!message || message.length < 20) {
return {
success: false,
fieldErrors: { message: 'Message must be at least 20 characters' }
};
}
try {
await sendEmail({ email, message });
return { success: true };
} catch {
return { success: false, error: 'Failed to send. Please try again.' };
}
}
Notice the signature: the Server Action now takes prevState as its first argument — useFormState passes the previous state on each submission.
// components/ContactForm.jsx
'use client';
import { useFormState } from 'react-dom';
import { submitContactForm } from '@/app/actions/contact';
import { SubmitButton } from './SubmitButton';
const initialState = { success: false };
export function ContactForm() {
const [state, formAction] = useFormState(submitContactForm, initialState);
if (state.success) {
return (
<div className="p-6 bg-green-50 rounded-xl text-green-700">
Message sent successfully. We'll be in touch soon.
</div>
);
}
return (
<form action={formAction}>
<div className="flex flex-col gap-4">
<div>
<input
name="email"
type="email"
placeholder="Your email"
className="w-full p-3 border rounded-lg"
aria-describedby="email-error"
/>
{state.fieldErrors?.email && (
<p id="email-error" className="text-sm text-red-500 mt-1">
{state.fieldErrors.email}
</p>
)}
</div>
<div>
<textarea
name="message"
placeholder="Your message"
className="w-full p-3 border rounded-lg h-32"
aria-describedby="message-error"
/>
{state.fieldErrors?.message && (
<p id="message-error" className="text-sm text-red-500 mt-1">
{state.fieldErrors.message}
</p>
)}
</div>
{state.error && (
<p className="text-sm text-red-500">{state.error}</p>
)}
<SubmitButton>Send Message</SubmitButton>
</div>
</form>
);
}
The form now: shows field-level errors from the server, shows a success state after submission, has an automatically pending submit button — all without any manual useState for pending or error state.
Combining Both Hooks
The pattern works best when both hooks are used together:
'use client';
import { useFormState } from 'react-dom';
import { useFormStatus } from 'react-dom';
// useFormStatus: used inside the form, shows pending state
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Save'}
</button>
);
}
// useFormState: wraps the action, provides state to the component
function EditProfileForm({ user }) {
const [state, action] = useFormState(updateProfile, { success: false });
return (
<form action={action}>
<input name="name" defaultValue={user.name} />
{state.fieldErrors?.name && <p>{state.fieldErrors.name}</p>}
<input name="bio" defaultValue={user.bio} />
{state.fieldErrors?.bio && <p>{state.fieldErrors.bio}</p>}
{state.success && <p>Profile updated!</p>}
{state.error && <p>{state.error}</p>}
<SubmitButton />
</form>
);
}
Progressive Enhancement
One significant benefit of this pattern: forms built this way work without JavaScript. The Server Action handles submission, the server returns a response, and the form shows the updated state — all without client-side JavaScript.
With JavaScript enabled, the experience is faster (no page reload, pending state feedback). Without JavaScript, it still works. This progressive enhancement happens automatically when you use native <form> elements with action attributes pointing to Server Actions.
When Not to Use This Pattern
Complex multi-step forms. Forms where each step depends on the previous step's data are better handled with client-side state management (Zustand or React Query) rather than Server Actions.
Optimistic updates. If you want to show the result immediately before the server confirms it, useOptimistic combined with useTransition is more appropriate than useFormState.
Real-time validation. useFormState triggers only on submission. For field-level validation as users type, client-side validation logic is still needed.
For most simple forms — contact, login, settings, profile — useFormState + useFormStatus is the cleanest pattern available in React 19 App Router.
Accessibility Considerations
The pattern above includes aria-describedby to connect error messages to their fields. This is important when using server-returned validation errors — screen readers need to know which field an error refers to.
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
aria-describedby={state.fieldErrors?.email ? "email-error" : undefined}
aria-invalid={state.fieldErrors?.email ? "true" : undefined}
/>
{state.fieldErrors?.email && (
<p id="email-error" role="alert">
{state.fieldErrors.email}
</p>
)}
</div>
The role="alert" on the error paragraph announces the error to screen readers when it appears — without requiring focus management.
Testing
Testing useFormState components requires wrapping the form in an act() call and simulating submission:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ContactForm } from './ContactForm';
// Mock the server action
jest.mock('@/app/actions/contact', () => ({
submitContactForm: jest.fn().mockResolvedValue({
success: true
})
}));
test('shows success state after submission', async () => {
render(<ContactForm />);
fireEvent.change(screen.getByPlaceholderText('Your email'), {
target: { value: 'test@example.com' }
});
fireEvent.change(screen.getByPlaceholderText('Your message'), {
target: { value: 'This is a test message that is long enough' }
});
fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));
await waitFor(() => {
expect(screen.getByText(/message sent successfully/i)).toBeInTheDocument();
});
});
The mock replaces the Server Action with a resolved promise, which is what useFormState expects.
Summary
useFormStatus gives any component inside a form instant access to the form's pending state — no prop drilling, no context.
useFormState wraps a Server Action to give the client component access to the action's return value — perfect for server-side validation errors and success states.
Together they cover most form handling needs in Next.js App Router with minimal client-side code, progressive enhancement built in, and clean separation between client and server concerns.
Top comments (0)