React 19 shipped four new hooks specifically for forms: useActionState, useFormStatus, useOptimistic, and useFormState (deprecated). If you've tried to upgrade an existing codebase and hit confusing import errors or unexpected behavior, this guide covers how all of them actually work.
The problem React 19 solved
In React 18, handling a form with a Server Action involved a lot of boilerplate:
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsPending(true);
try {
await submitForm(formData);
} catch (err) {
setError('Something went wrong');
} finally {
setIsPending(false);
}
}
React 19 replaces this with hooks that integrate directly with Server Actions — no e.preventDefault(), no manual state management.
useActionState — the one you'll use most
useActionState is the main hook for form state management. It replaces the deprecated useFormState from react-dom.
Critical: import it from react, not react-dom.
import { useActionState } from 'react'; // ✅ React 19
// NOT: import { useFormState } from 'react-dom'; // ❌ deprecated
How it works
const [state, action, isPending] = useActionState(serverAction, initialState);
-
state— the value returned by your Server Action -
action— pass to<form action={action}> -
isPending—truewhile the action is in flight
A real signup form
// app/actions/auth.ts
'use server';
import { z } from 'zod';
const signupSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(1, 'Name is required'),
});
export type SignupState = {
errors?: { email?: string[]; password?: string[]; name?: string[] };
message?: string;
success?: boolean;
};
export async function signup(
prevState: SignupState,
formData: FormData
): Promise<SignupState> {
const parsed = signupSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name'),
});
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
try {
await createUser(parsed.data);
return { success: true, message: 'Account created!' };
} catch {
return { message: 'An error occurred. Please try again.' };
}
}
// app/signup/page.tsx
'use client';
import { useActionState } from 'react';
import { signup, type SignupState } from '@/app/actions/auth';
const initialState: SignupState = {};
export default function SignupPage() {
const [state, action, isPending] = useActionState(signup, initialState);
if (state.success) return <p>Account created! Check your email.</p>;
return (
<form action={action}>
<input name="name" type="text" placeholder="Name" required />
{state.errors?.name && <p className="error">{state.errors.name[0]}</p>}
<input name="email" type="email" placeholder="Email" required />
{state.errors?.email && <p className="error">{state.errors.email[0]}</p>}
<input name="password" type="password" placeholder="Password" required />
{state.errors?.password && <p className="error">{state.errors.password[0]}</p>}
{state.message && <p className="error">{state.message}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Creating account...' : 'Sign up'}
</button>
</form>
);
}
💡 Tip: The Server Action always receives
prevStateas the first argument. If you forget it,formDatawill actually be the state object — everything breaks silently.
useFormStatus — loading states in child components
useFormStatus reads the status of the nearest parent <form>. The key constraint: it must be called in a child component, not in the form component itself.
import { useFormStatus } from 'react-dom'; // stays in react-dom
The SubmitButton pattern
// components/SubmitButton.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : children}
</button>
);
}
Why it must be a child
useFormStatus reads React context from the parent <form>. If you call it in the same component that renders the <form>, you're outside the provider — pending is always false.
// ❌ Wrong — useFormStatus in the same component as the form
function MyForm() {
const { pending } = useFormStatus(); // always false
return <form><button disabled={pending}>Submit</button></form>;
}
// ✅ Correct — useFormStatus in a child
function SubmitButton() {
const { pending } = useFormStatus(); // works correctly
return <button disabled={pending}>Submit</button>;
}
function MyForm() {
return <form><SubmitButton /></form>;
}
useOptimistic — instant UI updates
useOptimistic lets you show an update immediately while the server processes the action. If the action fails, React rolls back automatically.
import { useOptimistic } from 'react';
const [optimisticState, addOptimistic] = useOptimistic(
currentState,
updateFunction
);
Real example: instant todo list
'use client';
import { useOptimistic, useActionState } from 'react';
type Todo = { id: string; text: string; pending?: boolean };
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
const [, action] = useActionState(addTodo, {});
async function handleAddTodo(formData: FormData) {
const text = formData.get('text') as string;
addOptimisticTodo({ id: crypto.randomUUID(), text, pending: true });
await action(formData);
}
return (
<div>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.text}
</li>
))}
</ul>
<form action={handleAddTodo}>
<input name="text" placeholder="New todo" />
<button type="submit">Add</button>
</form>
</div>
);
}
⚠️ Warning: Call
addOptimisticinside a Server Action,startTransition, or a form action — not in a plain event handler. It won't work otherwise.
Migrating from useFormState
// Before (React 18 / react-dom)
import { useFormState } from 'react-dom';
const [state, action] = useFormState(serverAction, initialState);
// After (React 19 / react)
import { useActionState } from 'react';
const [state, action, isPending] = useActionState(serverAction, initialState);
The API is the same — change the import, rename the hook, and you get isPending for free.
Common mistakes
Mistake 1: Wrong import
import { useActionState } from 'react-dom'; // ❌ doesn't exist here
import { useActionState } from 'react'; // ✅
Mistake 2: useFormStatus in the form component — always false. Move to a child.
Mistake 3: Missing prevState in Server Action — formData will be the state object.
// ❌
export async function createPost(formData: FormData) {}
// ✅
export async function createPost(prevState: PostState, formData: FormData) {}
Mistake 4: Forgetting 'use client' — these are client hooks. The component must be a Client Component.
Quick reference
| Hook | Package | Purpose |
|---|---|---|
useActionState |
react |
Form state + isPending, replaces useFormState |
useFormStatus |
react-dom |
Submit button state, must be in child component |
useOptimistic |
react |
Instant UI, auto-reverts on failure |
Full guide with more examples: stacknotice.com/blog/react-19-form-hooks-useactionstate-guide-2026
Top comments (0)