Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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).

Three mental models cause most of the confusion. Action results vs form state: useActionState’s state is the action’s last return value, reset on every submit — not a place to store user input. useOptimistic vs useTransition: the first is a display layer for state that can lie temporarily; the second is a scheduling primitive marking an update as non-urgent. Both expose isPending but answer different questions. Server vs client actions: "use server" functions run only on the server and reach the client via RPC stubs; only the wrapped formAction or a direct <form action> reference has the RPC plumbing.

Diagnostic Timeline

A typical React 19 forms debugging session.

Minute 0. Submitting does nothing. First guess: “I need useActionState.” You add it; the form still does nothing. The real cause: you wrote <form onSubmit={formAction}> instead of <form action={formAction}>. They are different contracts.

Minute 6. Submit works but the spinner never appears. useFormStatus returns pending: false because you called it in the same component that renders the <form> — the hook only sees the enclosing form when called from a descendant. Hoist the submit button into its own component.

Minute 14. The optimistic message flashes, disappears, then reappears. First guess: “state desync.” The real cause: the action threw, so React never received a fresh messages prop, so useOptimistic reset to the previous state. Wrap the action in try/catch, return an error result, and the optimistic value stays until the real value lands.

Minute 23. Server Action throws A function from 'use server' was called during render. The error means the action was called as a regular reference during render, not inside an event handler. Move it into the action={...} prop or a click handler.

Minute 32. Double-clicks send two submissions even with disabled={isPending}. The first event queued before React applied isPending. Combine disabled={isPending} with a guard inside the action.

Minute 41. formData.get("title") returns null. A wrapping element broke native form scoping — every named input must be a descendant of the same <form>. Refactor or build FormData manually from refs.

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.
  • useOptimistic value updates synchronously but the UI does not. You called addOptimistic outside a Transition. React applies it on the next render. If the parent does not re-render (because no state above changed), the optimistic value is queued but invisible. Wrap the action in startTransition.
  • Server Action receives FormData with field values URL-encoded. A <input type="file"> in the same form causes React to switch encoding. Either split file uploads into their own form or read files via formData.get("file") as File rather than expecting a string.
  • useActionState state lags one submit behind. You are reading state inside the action body. The prev argument is the current state; state from the closure is stale. Use prev for any in-action logic.

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