Fix: React 19 Actions Not Working — useActionState, useFormStatus, useOptimistic, and form action
Quick Answer
How to fix React 19 actions errors — useActionState signature, form action vs onSubmit, useFormStatus must be in child, useOptimistic state desync, Server Actions in client components, and error handling.
The Error
You wire up useActionState and the form submits but state never updates:
"use client";
import { useActionState } from "react";
const [state, formAction] = useActionState(async (prev, formData) => {
return { ok: true };
}, null);
<form onSubmit={formAction}>...</form>
// state stays null forever.Or useFormStatus returns pending: false even mid-submit:
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "..." : "Submit"}</button>;
}
// Used outside the form — pending is always false.
<form action={...}>
<SubmitButton />
</form>Or useOptimistic value snaps back after the server responds:
const [optimistic, addOptimistic] = useOptimistic(messages, (state, newMsg) => [...state, newMsg]);
// Add new message → optimistic shows it → server responds → optimistic disappears.Or Server Actions throw in client components:
Error: A function from 'use server' was called during render.Why This Happens
React 19 introduced a coherent set of primitives for form submissions, pending UI, and optimistic updates:
form action={fn}— replacesonSubmit. Theactionprop on<form>accepts a function that receives FormData. Works with both Server Actions and client functions.useActionState— wraps an action with a state machine: returns[state, wrappedAction, isPending]. Use the wrapped action as the<form action>.useFormStatus— reads the pending state of the enclosing form. Must be in a descendant component, not the same component as the form.useOptimistic— gives you a separate state that you can modify imperatively while a server action is in flight. Resets to the real state once the action completes.
Most issues map to:
- Calling actions via
onSubmitinstead ofaction. - Using
useFormStatusin the same component as the form (wrong scope). - Misunderstanding
useOptimisticlifecycle. - Importing Server Actions wrong (need
"use server"and a separate module).
Fix 1: Use action Prop, Not onSubmit
"use client";
import { useActionState } from "react";
async function createPost(prevState: State, formData: FormData) {
const title = formData.get("title") as string;
// Validate, call API, etc.
if (!title) return { error: "Title required" };
// ...
return { ok: true };
}
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" />
<button disabled={isPending}>Submit</button>
{state?.error && <p>{state.error}</p>}
</form>
);
}Three things:
action={formAction}— pass the wrapped action returned byuseActionState.- Action signature:
(previousState, formData) => newState. isPending— the third element of the tuple, true during action execution.
You don’t need event.preventDefault() — React handles it.
For Server Actions, define them in a server-only module:
// app/actions.ts
"use server";
export async function createPost(prevState: State, formData: FormData) {
// ... DB write, revalidate, etc.
}// app/post-form.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "./actions";
export function PostForm() {
const [state, formAction] = useActionState(createPost, null);
return <form action={formAction}>...</form>;
}The "use server" directive at the file’s top marks every export as a Server Action — they’re callable from clients via a special RPC, and the implementation runs only on the server.
Pro Tip: Server Actions can also be called directly (without useActionState) by passing them straight to <form action={createPost}>. useActionState adds state tracking; the raw action is fine if you only care about side effects.
Fix 2: useFormStatus Must Be in a Descendant
useFormStatus reads its parent form’s state. It only works inside a component nested under the form:
// WRONG — useFormStatus in the same component as the form:
function PostForm() {
const { pending } = useFormStatus(); // Always false
return (
<form action={...}>
<button disabled={pending}>Submit</button>
</form>
);
}
// RIGHT — useFormStatus in a child component:
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "..." : "Submit"}</button>;
}
function PostForm() {
return (
<form action={...}>
<input name="title" />
<SubmitButton />
</form>
);
}React’s useFormStatus walks the component tree to find the nearest <form> ancestor. If you call it in the same component that renders the form, that form doesn’t exist in the parent chain yet — it’s a sibling.
This is intentional: it lets shared button components (like <SubmitButton />) react to any form they’re dropped into without prop drilling.
For more form info:
function SubmitInfo() {
const { pending, data, method, action } = useFormStatus();
return pending ? <p>Submitting via {method?.toUpperCase()}...</p> : null;
}data is the FormData being submitted; action is the action function; method is the HTTP method.
Fix 3: useOptimistic for Instant UI
useOptimistic lets you add temporary state during an action’s execution:
"use client";
import { useOptimistic } from "react";
function MessageList({ messages, addMessage }: Props) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newMessage: string) => [
...state,
{ id: "temp-" + Date.now(), text: newMessage, sending: true },
],
);
async function handleSubmit(formData: FormData) {
const text = formData.get("message") as string;
addOptimistic(text); // UI updates instantly
await addMessage(text); // Server call — UI shows "sending" until done
}
return (
<>
<ul>
{optimisticMessages.map((m) => (
<li key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>
{m.text}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="message" />
<button>Send</button>
</form>
</>
);
}How it works:
- User submits →
addOptimistic(text)adds the message tooptimisticMessagesimmediately. - The form action runs (in this case, calls
addMessageserver-side). - When the action returns and React re-renders the parent (with the new server-side
messagesarray passed as a prop),optimisticMessagesresets to the real state.
The “temp-…” optimistic message disappears because the real message (with its server-assigned ID) is now in messages. The transition is seamless if your server-side message has the same text.
Common Mistake: Using useOptimistic for state the server doesn’t return. The optimistic update has to be “replaceable” by a real version. For UI-only optimism (e.g. a loading spinner), use useState or useFormStatus.
Fix 4: Combine the Three
A complete form with state, pending UI, and optimistic updates:
"use client";
import { useActionState, useFormStatus, useOptimistic } from "react";
import { addTodo, type Todo } from "./actions";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Adding..." : "Add"}</button>;
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [state, formAction] = useActionState(
async (prev: { error?: string } | null, formData: FormData) => {
try {
const todo = await addTodo(formData);
return { error: undefined };
} catch (err) {
return { error: (err as Error).message };
}
},
null,
);
const [optimisticTodos, addOptimistic] = useOptimistic(
initialTodos,
(state, newTodo: string) => [...state, { id: "temp", text: newTodo, done: false }],
);
return (
<>
<ul>
{optimisticTodos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
<form
action={(formData) => {
addOptimistic(formData.get("text") as string);
formAction(formData);
}}
>
<input name="text" required />
<SubmitButton />
</form>
{state?.error && <p style={{ color: "red" }}>{state.error}</p>}
</>
);
}The form’s action does two things:
- Calls
addOptimistic(...)immediately for the snappy UI. - Calls
formAction(formData)to invoke the wrapped action (which handles errors and updatesstate).
SubmitButton reads pending state from useFormStatus — no need to thread isPending from useActionState to it.
Fix 5: Server Actions With Revalidation
In Next.js App Router, Server Actions integrate with cache revalidation:
// app/actions.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { db } from "@/lib/db";
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get("title") as string;
if (!title) return { error: "Title required" };
const post = await db.post.create({ data: { title } });
revalidatePath("/posts");
return { ok: true, post };
}revalidatePath invalidates the Next.js cache for that route — the next visit re-fetches. Without it, the user sees the old post list because of cache.
For tag-based caching:
import { revalidateTag } from "next/cache";
export async function createPost(prevState, formData) {
await db.post.create(...);
revalidateTag("posts"); // Invalidates all fetches tagged "posts"
}In your data fetch:
const posts = await fetch("/api/posts", { next: { tags: ["posts"] } });Common Mistake: Forgetting revalidatePath — the form succeeds but the user doesn’t see the new state until they refresh manually.
Fix 6: Error Handling
For per-field errors:
type State = {
errors?: {
title?: string[];
body?: string[];
};
ok?: boolean;
};
async function createPost(prev: State, formData: FormData): Promise<State> {
const title = formData.get("title") as string;
const body = formData.get("body") as string;
const errors: State["errors"] = {};
if (!title) errors.title = ["Title is required"];
if (body.length < 10) errors.body = ["Body must be at least 10 characters"];
if (Object.keys(errors).length > 0) return { errors };
await db.post.create({ data: { title, body } });
return { ok: true };
}
// In the component:
{state?.errors?.title?.map((e) => <p key={e}>{e}</p>)}For Zod-validated forms:
import { z } from "zod";
const schema = z.object({
title: z.string().min(1, "Title is required"),
body: z.string().min(10, "Body must be at least 10 characters"),
});
export async function createPost(prev, formData) {
const result = schema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.post.create({ data: result.data });
return { ok: true };
}error.flatten().fieldErrors gives you { field: string[] } — matches the State shape above.
Fix 7: Reset Form After Success
useActionState’s wrapped action doesn’t reset the form. For “submit succeeded, clear the form”:
"use client";
import { useEffect, useRef } from "react";
import { useActionState } from "react";
export function PostForm() {
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction] = useActionState(createPost, null);
useEffect(() => {
if (state?.ok) {
formRef.current?.reset();
}
}, [state]);
return (
<form ref={formRef} action={formAction}>
<input name="title" />
<button>Submit</button>
</form>
);
}Or use a controlled input cleared in an effect:
const [title, setTitle] = useState("");
useEffect(() => {
if (state?.ok) setTitle("");
}, [state]);
return (
<form action={formAction}>
<input name="title" value={title} onChange={(e) => setTitle(e.target.value)} />
</form>
);Pro Tip: Controlled inputs play less well with Server Actions because the form is implicitly re-rendered on action completion. Uncontrolled (use ref + formRef.current.reset()) is simpler.
Fix 8: TypeScript Types
import { useActionState } from "react";
type State = { ok?: boolean; error?: string };
async function action(prev: State | null, formData: FormData): Promise<State> {
return { ok: true };
}
export function Form() {
const [state, formAction, isPending] = useActionState<State | null, FormData>(
action,
null,
);
// state: State | null
// formAction: (formData: FormData) => void
// isPending: boolean
}The two generics are <StateType, ActionPayloadType>. The payload type is FormData when used with <form action>.
For inferring from the action function:
const [state, formAction] = useActionState(action, null);
// state: State | null — inferred from action's return typeInference works in most cases; only add explicit generics if TS can’t figure it out.
Still Not Working?
A few less-obvious failures:
useActionStatedoesn’t return three elements. You’re on an older React 19 prerelease. Update to 19.x stable; the API is[state, action, isPending].- Server Action causes full page reload. JavaScript disabled or the form was rendered as plain HTML. Server Actions need React’s enhanced form to handle interception. Wrap with a Client Component.
Cannot read properties of undefined (reading 'flatten'). Zod’ssafeParsereturned success but you tried to readerror. Checkresult.successfirst.- Optimistic state leaks into next render. You called
addOptimisticbut the action threw before completing.useOptimisticonly resets when the action successfully returns or React re-renders the parent. Wrap the action in try/catch and re-throw to propagate errors properly. - Pending UI flashes briefly. Action returned too fast. Add a minimum loading time only if needed for UX — usually fast is good.
useFormStatusreturns pending: true forever. Action threw without resolving. Add error handling so the promise resolves.- Multiple submits before completion. React 19 doesn’t auto-block double-submits. Set
disabledbased onisPending(oruseFormStatus’s pending) on the submit button. form actiondoesn’t support GET method. Server Actions are POST-only. For GET-style forms, useonSubmitwithuseFormState-style state tracking, or just navigate withuseRouter.
For related React and Next.js form issues, see React Hook Form not working, TanStack Form not working, Next.js server action not working, and React useState not updating.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Next.js 'params should be awaited before using its properties'
How to fix Next.js 15 async params and searchParams errors — await in Server Components, React.use in Client Components, generateMetadata, generateStaticParams, and the codemod migration path.
Fix: next-themes Not Working — Hydration Mismatch, Tailwind Dark Mode, FOUC, and System Preference
How to fix next-themes errors — hydration mismatch on mount, FOUC flash before theme applies, Tailwind dark: classes not switching, ThemeProvider in App Router, defaultTheme system not respected, and TypeScript types.
Fix: React Compiler Not Working — ESLint Plugin, Babel Setup, Bail-Outs, and Vite/Next.js Config
How to fix React Compiler issues — eslint-plugin-react-compiler not flagging, babel-plugin-react-compiler not running, 'Function contains a code construct that prevents compilation', Next.js 15 config, and removing useMemo/useCallback safely.
Fix: SWR Not Working — Key Changes, Mutate Not Updating, Conditional Fetching, and SSR Hydration
How to fix SWR errors — useSWR not refetching on key change, mutate not invalidating, conditional null key, fallbackData vs fallback, SSR hydration mismatch, infinite scroll pagination, and TypeScript typing.