useActionState Hook
Manage form state driven by an action function, with built-in pending state and progressive enhancement.
Recipe
Quick-reference recipe card — copy-paste ready.
const [state, formAction, isPending] = useActionState(action, initialState);
// action signature
async function action(previousState: State, formData: FormData): Promise<State> {
// process form data, return new state
}
// Use in a form
<form action={formAction}>
<input name="email" />
<button disabled={isPending}>Submit</button>
{state.error && <p>{state.error}</p>}
</form>When to reach for this: You have a form that submits data (to a server action or async function) and you want React to manage the submission state, pending indicator, and result — with progressive enhancement (works without JavaScript).
Working Example
"use client";
import { useActionState } from "react";
interface FormState {
message: string;
error: string;
}
async function submitFeedback(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const feedback = formData.get("feedback") as string;
if (!feedback || feedback.trim().length < 10) {
return { message: "", error: "Feedback must be at least 10 characters." };
}
// Simulate server delay
await new Promise((resolve) => setTimeout(resolve, 1000));
return { message: `Thanks for your feedback!`, error: "" };
}
const initialState: FormState = { message: "", error: "" };
export function FeedbackForm() {
const [state, formAction, isPending] = useActionState(submitFeedback, initialState);
return (
<form action={formAction} className="space-y-3 max-w-sm">
<label className="block">
<span className="text-sm font-medium">Your Feedback</span>
<textarea
name="feedback"
rows={3}
className="mt-1 block w-full border rounded px-3 py-2"
required
/>
</label>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isPending ? "Submitting..." : "Submit"}
</button>
{state.error && <p className="text-sm text-red-600">{state.error}</p>}
{state.message && <p className="text-sm text-green-600">{state.message}</p>}
</form>
);
}What this demonstrates:
useActionStatemanages the entire form lifecycle: idle, pending, success, and error- The action receives the previous state and
FormData, returning the next state isPendingdisables the button and shows loading text during submission- The form works with progressive enhancement — it can submit even before JavaScript loads
Deep Dive
How It Works
useActionStatewraps your action function and returns a form-compatible action, the current state, and a pending flag- When the form submits, React calls your action with the previous state and the
FormData - The action runs inside a transition, so
isPendingbecomestruewithout blocking the UI - After the action resolves, React updates the state with the returned value and sets
isPendingtofalse - The
formActionreturned is compatible with the<form action={}>pattern, enabling progressive enhancement - If used with server actions in Next.js, the form works before JavaScript hydrates
Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
action | (prevState: S, formData: FormData) => S or Promise<S> | Function called on form submission |
initialState | S | Initial state before any submission |
permalink | string (optional) | URL for progressive enhancement (server components) |
| Return | Type | Description |
|---|---|---|
state | S | Current state (updated after each action completes) |
formAction | (formData: FormData) => void | Action to pass to <form action={}> or <button formAction={}> |
isPending | boolean | true while the action is running |
Variations
With server action (Next.js App Router):
// app/actions.ts
"use server";
export async function createUser(prevState: FormState, formData: FormData) {
const name = formData.get("name") as string;
const user = await db.users.create({ data: { name } });
return { success: true, error: "" };
}
// app/page.tsx
"use client";
import { useActionState } from "react";
import { createUser } from "./actions";
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, {
success: false,
error: "",
});
return <form action={formAction}>...</form>;
}Multiple submit buttons with formAction:
<form>
<input name="item" />
<button formAction={saveAction}>Save Draft</button>
<button formAction={publishAction}>Publish</button>
</form>Client-only async action:
async function loginAction(prev: LoginState, formData: FormData) {
const res = await fetch("/api/login", {
method: "POST",
body: formData,
});
if (!res.ok) return { error: "Invalid credentials" };
return { error: "" };
}TypeScript Notes
// Type the state explicitly for clarity
interface ActionState {
success: boolean;
error: string;
data?: UserData;
}
// The action must match the state type
async function myAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// ...
return { success: true, error: "" };
}
const [state, formAction, isPending] = useActionState(myAction, {
success: false,
error: "",
});
// state: ActionStateGotchas
-
Confusing with useFormState (deprecated) — React 19 renamed
useFormStatetouseActionStateand addedisPendingas the third return value. Fix: UseuseActionStatefrom"react", notuseFormStatefrom"react-dom". -
Action must return state — If your action doesn't return a value, state becomes
undefinedafter submission. Fix: Always return the new state from your action function. -
State resets on each submission — The previous state is passed as the first argument; you must merge it if you want to preserve fields. Fix: Spread previous state:
return { ...prevState, error: "" }. -
Using outside a form —
useActionStateis designed for<form action={}>. CallingformActionmanually with constructedFormDataworks but loses progressive enhancement. Fix: Prefer<form action={formAction}>for best compatibility. -
Server action serialization — State passed between server and client must be serializable (no functions, Dates, Maps). Fix: Use plain objects with primitive values.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
useState + useTransition | Custom submit logic not tied to <form action={}> | You want progressive enhancement |
useReducer | Complex client-side state transitions without form submission | State changes are driven by form actions |
| React Hook Form | Complex validation, field-level errors, dynamic forms | Simple forms with server actions |
| Server action without hook | Fire-and-forget mutation, no client state update needed | You need to display the result in the UI |
Why useActionState over manual fetch? useActionState gives you pending state, error handling, and progressive enhancement in one hook — no need to wire up useState + useTransition + try/catch manually.
FAQs
What is the difference between useActionState and the deprecated useFormState?
- React 19 renamed
useFormStatetouseActionStateand addedisPendingas the third return value. - Import
useActionStatefrom"react", notuseFormStatefrom"react-dom". - The API is otherwise the same: action function, initial state, and optional permalink.
How does useActionState provide progressive enhancement?
- The
formActionreturned by the hook is compatible with<form action={}>. - This means the form can submit even before JavaScript hydrates on the page.
- With server actions in Next.js, the form works without any client-side JavaScript.
Why does the action function receive previousState as its first argument?
- It allows incremental state updates, similar to a reducer pattern.
- You can merge the previous state with new data:
return { ...prevState, error: "" }. - Without it, you would lose existing state fields on each submission.
Gotcha: What happens if my action function doesn't return a value?
- The state becomes
undefinedafter submission, which may break your UI. - Always return the new state object from the action function.
- TypeScript will warn you if the return type doesn't match the state type.
Can I use useActionState for client-only forms without server actions?
async function loginAction(prev: LoginState, formData: FormData) {
const res = await fetch("/api/login", {
method: "POST",
body: formData,
});
if (!res.ok) return { error: "Invalid credentials" };
return { error: "" };
}
const [state, formAction, isPending] = useActionState(loginAction, { error: "" });- Yes. The action can be any async function, not just a server action.
How does isPending work internally?
- The action runs inside a React transition, so
isPendingbecomestruewithout blocking the UI. - After the action resolves, React updates the state and sets
isPendingtofalse. - You don't need to manage loading state manually.
How do I type the action and state with TypeScript?
interface ActionState {
success: boolean;
error: string;
}
async function myAction(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
return { success: true, error: "" };
}
const [state, formAction, isPending] = useActionState(myAction, {
success: false,
error: "",
});
// state: ActionStateGotcha: Can I pass non-serializable values (functions, Dates, Maps) in the state?
- No. When using server actions, state is serialized between server and client.
- Only plain objects with primitive values (strings, numbers, booleans, arrays) are safe.
- Functions,
Dateobjects,Map, andSetwill fail during serialization.
How do I handle multiple submit buttons with different actions?
<form>
<input name="item" />
<button formAction={saveDraftAction}>Save Draft</button>
<button formAction={publishAction}>Publish</button>
</form>- Each button can have its own
formActionattribute pointing to a different action.
When should I use useActionState vs useState + useTransition manually?
- Use
useActionStatefor form submissions where you want progressive enhancement and automatic pending state. - Use
useState+useTransitionfor custom submit logic not tied to<form action={}>. useActionStatereduces boilerplate by combining state, pending, and action into one hook.
Related
- useOptimistic — show optimistic state while the action is pending
- useTransition —
useActionStateuses transitions internally - use — React 19 primitive for consuming promises
- useReducer — similar pattern but for client-side state machines