Skip to content

Fix: React 19 Actions Not Working — useActionState, useFormStatus, useOptimistic, and form action

FixDevs ·

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} — replaces onSubmit. The action prop 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 onSubmit instead of action.
  • Using useFormStatus in the same component as the form (wrong scope).
  • Misunderstanding useOptimistic lifecycle.
  • 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:

  1. action={formAction} — pass the wrapped action returned by useActionState.
  2. Action signature: (previousState, formData) => newState.
  3. 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:

  1. User submits → addOptimistic(text) adds the message to optimisticMessages immediately.
  2. The form action runs (in this case, calls addMessage server-side).
  3. When the action returns and React re-renders the parent (with the new server-side messages array passed as a prop), optimisticMessages resets 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:

  1. Calls addOptimistic(...) immediately for the snappy UI.
  2. Calls formAction(formData) to invoke the wrapped action (which handles errors and updates state).

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 type

Inference works in most cases; only add explicit generics if TS can’t figure it out.

Still Not Working?

A few less-obvious failures:

  • useActionState doesn’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’s safeParse returned success but you tried to read error. Check result.success first.
  • Optimistic state leaks into next render. You called addOptimistic but the action threw before completing. useOptimistic only 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.
  • useFormStatus returns 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 disabled based on isPending (or useFormStatus’s pending) on the submit button.
  • form action doesn’t support GET method. Server Actions are POST-only. For GET-style forms, use onSubmit with useFormState-style state tracking, or just navigate with useRouter.

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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles