React provides two powerful hooks for forms: useFormStatus for accessing submission status (loading states, pending data) and useFormState for managing form-level state that updates with Server Action results. Together, they enable responsive forms with instant feedback, loading indicators, and progressive enhancement. Let's build forms that feel fast and provide excellent UX!
useFormStatus Hook
What is useFormStatus?
useFormStatus is a React hook that provides status information about the parent <form> submission:
useFormStatus Returns:
- pending: Boolean - true during form submission
- data: FormData being submitted (or null)
- method: HTTP method ('get' or 'post')
- action: Function reference or URL
⚠️ Critical: useFormStatus Must Be in Child Component
useFormStatus must be called from a component that is rendered inside the <form> element. It won't work in the same component that renders the form.
Basic Submit Button with Loading State
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// ✅ pending is true during submission
// ✅ Button disabled while pending
// ✅ Text changes to show loading
// ✅ Must be used in child componentimport { SubmitButton } from '@/app/components/SubmitButton';
import { submitContact } from '@/app/actions/contact';
export default function ContactPage() {
return (
<form action={submitContact} className="space-y-6">
<input
name="name"
placeholder="Your name"
required
className="w-full px-4 py-2 border rounded"
/>
<textarea
name="message"
placeholder="Your message"
required
className="w-full px-4 py-2 border rounded"
/>
{/* SubmitButton uses useFormStatus */}
<SubmitButton />
</form>
);
}
// ✅ SubmitButton is child of form
// ✅ Automatically gets pending state
// ✅ No manual state management neededSubmit Button Variations
'use client';
import { useFormStatus } from 'react-dom';
// Basic button with spinner
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg disabled:bg-gray-400"
>
{pending && (
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{pending ? 'Submitting...' : children}
</button>
);
}
// Button with custom pending text
export function SaveButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-6 py-3 bg-green-600 text-white rounded-lg disabled:opacity-50"
>
{pending ? (
<>
<span className="inline-block animate-pulse">⏳</span> Saving...
</>
) : (
<>💾 Save</>
)}
</button>
);
}
// Delete button with confirmation
export function DeleteButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-red-300"
>
{pending ? 'Deleting...' : 'Delete'}
</button>
);
}
// ✅ Spinner during loading
// ✅ Custom icons and text
// ✅ Different button styles
// ✅ Reusable componentsUsing All useFormStatus Properties
'use client';
import { useFormStatus } from 'react-dom';
export function FormDebug() {
const { pending, data, method, action } = useFormStatus();
return (
<div className="p-4 bg-gray-100 rounded text-sm font-mono">
<p><strong>Pending:</strong> {pending ? 'Yes' : 'No'}</p>
<p><strong>Method:</strong> {method || 'None'}</p>
<p><strong>Action:</strong> {action ? 'Set' : 'None'}</p>
{data && (
<div className="mt-2">
<strong>Form Data:</strong>
<ul className="ml-4">
{Array.from(data.entries()).map(([key, value]) => (
<li key={key}>
{key}: {value.toString()}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// Usage:
<form action={submitForm}>
<input name="username" />
<FormDebug /> {/* Shows all form status */}
<SubmitButton />
</form>
// ✅ Access all status properties
// ✅ Useful for debugging
// ✅ See what's being submitteduseFormState Hook
What is useFormState?
useFormState manages form-level state that updates based on Server Action results. Perfect for showing errors, success messages, or any state that changes with submissions.
useFormState Pattern:
const [state, formAction] = useFormState(serverAction, initialState);
// state: Current state (updates with Server Action return)
// formAction: Wrapped action to use in form
// serverAction: Your Server Action
// initialState: Starting state valueServer Action Signature with useFormState
'use server';
// Server Action for useFormState receives prevState as first parameter
export async function createPost(
prevState: { message: string } | null,
formData: FormData
) {
const title = formData.get('title') as string;
// Validate
if (!title || title.length < 3) {
return {
message: 'Title must be at least 3 characters',
};
}
try {
// Create post
await db.posts.create({
data: { title },
});
return {
message: 'Post created successfully!',
};
} catch (error) {
return {
message: 'Failed to create post',
};
}
}
// ✅ First parameter: prevState
// ✅ Second parameter: formData
// ✅ Return new state
// ✅ State accumulates across submissionsBasic useFormState Example
'use client';
import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from './SubmitButton';
const initialState = {
message: '',
};
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="title" className="block font-semibold mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
required
className="w-full px-4 py-2 border rounded"
/>
</div>
<SubmitButton />
{/* Display message from state */}
{state?.message && (
<p
className={`p-4 rounded ${
state.message.includes('success')
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{state.message}
</p>
)}
</form>
);
}
// ✅ useFormState manages message state
// ✅ formAction used instead of direct action
// ✅ state updates with Server Action return
// ✅ Display success/error messagesForm with Field Errors
'use server';
import { z } from 'zod';
const postSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
content: z.string().min(10, 'Content must be at least 10 characters'),
category: z.enum(['tech', 'design', 'business'], {
errorMap: () => ({ message: 'Invalid category' }),
}),
});
type FormState = {
message?: string;
errors?: {
title?: string[];
content?: string[];
category?: string[];
};
};
export async function createPost(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Validate
const result = postSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
category: formData.get('category'),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
try {
await db.posts.create({
data: result.data,
});
return {
message: 'Post created successfully!',
};
} catch (error) {
return {
message: 'Failed to create post',
};
}
}
// ✅ Type-safe state
// ✅ Field-specific errors
// ✅ Success message
// ✅ Zod validation'use client';
import { useFormState } from 'react-dom';
import { createPost } from '@/app/actions/posts';
import { SubmitButton } from './SubmitButton';
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, {});
return (
<form action={formAction} className="space-y-6">
{/* Title field */}
<div>
<label htmlFor="title" className="block font-semibold mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
className={`w-full px-4 py-2 border rounded ${
state?.errors?.title ? 'border-red-500' : 'border-gray-300'
}`}
/>
{state?.errors?.title && (
<p className="text-red-600 text-sm mt-1">
{state.errors.title[0]}
</p>
)}
</div>
{/* Content field */}
<div>
<label htmlFor="content" className="block font-semibold mb-2">
Content
</label>
<textarea
id="content"
name="content"
rows={10}
className={`w-full px-4 py-2 border rounded ${
state?.errors?.content ? 'border-red-500' : 'border-gray-300'
}`}
/>
{state?.errors?.content && (
<p className="text-red-600 text-sm mt-1">
{state.errors.content[0]}
</p>
)}
</div>
{/* Category field */}
<div>
<label htmlFor="category" className="block font-semibold mb-2">
Category
</label>
<select
id="category"
name="category"
className={`w-full px-4 py-2 border rounded ${
state?.errors?.category ? 'border-red-500' : 'border-gray-300'
}`}
>
<option value="">Select category</option>
<option value="tech">Technology</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
{state?.errors?.category && (
<p className="text-red-600 text-sm mt-1">
{state.errors.category[0]}
</p>
)}
</div>
<SubmitButton />
{/* Success message */}
{state?.message && (
<p className="p-4 bg-green-100 text-green-800 rounded">
{state.message}
</p>
)}
</form>
);
}
// ✅ Field-specific error display
// ✅ Visual error states (red border)
// ✅ Success message
// ✅ Type-safe state accessCombining Both Hooks
Complete Form with Loading and State
'use client';
import { useFormState } from 'react-dom';
import { useFormStatus } from 'react-dom';
import { addTodo } from '@/app/actions/todos';
// Submit button with useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-6 py-3 bg-blue-600 text-white rounded-lg disabled:bg-gray-400 flex items-center gap-2"
>
{pending && (
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
</svg>
)}
{pending ? 'Adding...' : 'Add Todo'}
</button>
);
}
// Form with useFormState
export function TodoForm() {
const [state, formAction] = useFormState(addTodo, {});
return (
<form action={formAction} className="space-y-4">
<div>
<input
type="text"
name="text"
placeholder="What needs to be done?"
required
className={`w-full px-4 py-2 border rounded ${
state?.error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{state?.error && (
<p className="text-red-600 text-sm mt-1">{state.error}</p>
)}
</div>
<SubmitButton />
{state?.success && (
<p className="text-green-600">Todo added successfully!</p>
)}
</form>
);
}
// ✅ useFormStatus in SubmitButton
// ✅ useFormState in form
// ✅ Loading state from useFormStatus
// ✅ Error/success from useFormState
// ✅ Complete user feedbackMulti-Step Form with State
'use server';
type RegistrationState = {
step: number;
data: {
name?: string;
email?: string;
password?: string;
};
errors?: Record<string, string>;
};
export async function handleRegistrationStep(
prevState: RegistrationState,
formData: FormData
): Promise<RegistrationState> {
const currentStep = prevState.step;
if (currentStep === 1) {
// Step 1: Name and email
const name = formData.get('name') as string;
const email = formData.get('email') as string;
if (!name || name.length < 2) {
return {
...prevState,
errors: { name: 'Name must be at least 2 characters' },
};
}
if (!email || !email.includes('@')) {
return {
...prevState,
errors: { email: 'Invalid email' },
};
}
return {
step: 2,
data: { ...prevState.data, name, email },
};
}
if (currentStep === 2) {
// Step 2: Password
const password = formData.get('password') as string;
if (!password || password.length < 8) {
return {
...prevState,
errors: { password: 'Password must be at least 8 characters' },
};
}
// Complete registration
await db.users.create({
data: {
name: prevState.data.name!,
email: prevState.data.email!,
password,
},
});
return {
step: 3,
data: { ...prevState.data, password },
};
}
return prevState;
}
// ✅ Multi-step state management
// ✅ Accumulate data across steps
// ✅ Step-specific validation
// ✅ Progressive disclosure'use client';
import { useFormState } from 'react-dom';
import { handleRegistrationStep } from '@/app/actions/registration';
import { SubmitButton } from './SubmitButton';
const initialState = {
step: 1,
data: {},
};
export function MultiStepForm() {
const [state, formAction] = useFormState(
handleRegistrationStep,
initialState
);
if (state.step === 3) {
return (
<div className="text-center p-8 bg-green-50 rounded-lg">
<h2 className="text-2xl font-bold text-green-800 mb-2">
Registration Complete!
</h2>
<p className="text-gray-600">Welcome, {state.data.name}!</p>
</div>
);
}
return (
<div className="max-w-md mx-auto">
{/* Progress indicator */}
<div className="mb-8">
<div className="flex justify-between mb-2">
<span className={state.step >= 1 ? 'text-blue-600 font-semibold' : 'text-gray-400'}>
Step 1
</span>
<span className={state.step >= 2 ? 'text-blue-600 font-semibold' : 'text-gray-400'}>
Step 2
</span>
<span className={state.step >= 3 ? 'text-blue-600 font-semibold' : 'text-gray-400'}>
Complete
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${(state.step / 3) * 100}%` }}
/>
</div>
</div>
<form action={formAction} className="space-y-6">
{state.step === 1 && (
<>
<h2 className="text-2xl font-bold mb-4">Personal Information</h2>
<div>
<label className="block font-semibold mb-2">Name</label>
<input
type="text"
name="name"
defaultValue={state.data.name}
className="w-full px-4 py-2 border rounded"
/>
{state.errors?.name && (
<p className="text-red-600 text-sm mt-1">{state.errors.name}</p>
)}
</div>
<div>
<label className="block font-semibold mb-2">Email</label>
<input
type="email"
name="email"
defaultValue={state.data.email}
className="w-full px-4 py-2 border rounded"
/>
{state.errors?.email && (
<p className="text-red-600 text-sm mt-1">{state.errors.email}</p>
)}
</div>
<SubmitButton>Next Step</SubmitButton>
</>
)}
{state.step === 2 && (
<>
<h2 className="text-2xl font-bold mb-4">Create Password</h2>
<div>
<label className="block font-semibold mb-2">Password</label>
<input
type="password"
name="password"
className="w-full px-4 py-2 border rounded"
/>
{state.errors?.password && (
<p className="text-red-600 text-sm mt-1">
{state.errors.password}
</p>
)}
</div>
<SubmitButton>Complete Registration</SubmitButton>
</>
)}
</form>
</div>
);
}
// ✅ Multi-step form with state
// ✅ Progress indicator
// ✅ Data persists between steps
// ✅ Step-specific validation
// ✅ Success screenForm Hooks Project Structure
Organization of forms with useFormStatus and useFormState
Select a file or folder to see details
Form Hooks Best Practices
1. useFormStatus in Separate Component
// ✅ GOOD: useFormStatus in child component
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>Submit</button>;
}
function MyForm() {
return (
<form action={submitAction}>
<input name="field" />
<SubmitButton />
</form>
);
}
// ❌ BAD: useFormStatus in same component as form
function MyForm() {
const { pending } = useFormStatus(); // Won't work!
return (
<form action={submitAction}>
<input name="field" />
<button disabled={pending}>Submit</button>
</form>
);
}2. Type-Safe State with useFormState
// ✅ GOOD: Type-safe state
type FormState = {
message?: string;
errors?: Record<string, string[]>;
};
export async function submitForm(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Implementation
}
const [state, formAction] = useFormState<FormState>(submitForm, {});
// Type safety throughout
if (state.errors?.email) {
// TypeScript knows this is string[]
}3. Clear State on Success
// ✅ GOOD: Clear form after success
'use client';
import { useFormState } from 'react-dom';
import { useEffect, useRef } from 'react';
export function MyForm() {
const [state, formAction] = useFormState(submitAction, {});
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state.success]);
return (
<form ref={formRef} action={formAction}>
{/* Form fields */}
</form>
);
}
// Form resets after successful submission4. Disable Inputs During Submission
// ✅ GOOD: Disable all inputs during submission
function FormInputs() {
const { pending } = useFormStatus();
return (
<>
<input name="email" disabled={pending} />
<input name="password" disabled={pending} />
<select name="role" disabled={pending}>
<option>Admin</option>
<option>User</option>
</select>
</>
);
}
// Prevents editing during submission5. Show Loading Indicators
// ✅ GOOD: Multiple loading indicators
function SubmitButton() {
const { pending } = useFormStatus();
return (
<div className="space-y-2">
<button
type="submit"
disabled={pending}
className="w-full px-6 py-3 bg-blue-600 text-white rounded disabled:bg-gray-400"
>
{pending ? (
<span className="flex items-center justify-center gap-2">
<Spinner />
Submitting...
</span>
) : (
'Submit'
)}
</button>
{pending && (
<p className="text-sm text-gray-600 text-center">
Please wait, this may take a few seconds...
</p>
)}
</div>
);
}
// Clear visual feedbackKey Takeaways
- useFormStatus - access form submission status
- pending property - true during submission
- Child component required - useFormStatus must be in form child
- useFormState - manage form-level state
- prevState parameter - Server Actions receive previous state
- formAction - use wrapped action in form
- State accumulation - perfect for multi-step forms
- Type safety - TypeScript support throughout
What's Next?
You've mastered useFormStatus and useFormState! Next, we'll explore Revalidating Data After Mutations—updating cached data after Server Actions using revalidatePath and revalidateTag, managing cache invalidation, and ensuring users always see fresh data. You'll build apps with instant updates!
We'll cover revalidation strategies, cache tags, on-demand revalidation, and building optimistic UI updates that feel instant.
⚡ Progressive Enhancement
Both hooks work with progressive enhancement! Forms work without JavaScript, and when JS loads, they enhance with loading states and instant feedback. This gives the best of both worlds—reliability and great UX.