Back to Articles
October 27, 202612 min readAmrit Sapkota

React 19 Forms: useActionState, useFormStatus and useOptimistic

React 19 Forms: useActionState, useFormStatus and useOptimistic — article by Amrit Sapkota

Every React form I wrote before React 19 had the same skeleton: an onSubmit handler, e.preventDefault(), one useState for loading, another for errors, and a try/finally so the spinner never got stuck. React 19 forms replace that skeleton with a function passed to the action prop and three hooks that each do one job. In this post we build a single comment form step by step: validation errors, a pending button, an optimistic comment, and a Next.js Server Action that still works before JavaScript loads. Along the way we hit the reset behaviour that catches almost everyone. Everything below was tested on React 19.3 and Next.js 16.4.

How form actions work in React 19

Pass a function to a form’s action prop and React takes over the submit. It calls your function with a FormData object built from the form’s named fields, runs it inside a transition, and treats the form as pending until the returned promise settles. No preventDefault, and no state for every input:

// comment-form.tsx
export function CommentForm() {
  async function submit(formData: FormData) {
    await fetch("/api/comments", { method: "POST", body: formData });
  }

  return (
    <form action={submit}>
      <textarea name="body" required />
      <button type="submit">Post comment</button>
    </form>
  );
}

Two rules come with this. Fields are read by their name attribute, so an input without one is simply missing from the FormData. And when the action finishes, React resets the form’s uncontrolled fields. That second rule is convenient on success and a trap on failure; we will get to it. What this version can’t do is show a result, which is the first hook’s job.

useActionState: errors, results and isPending in one hook

useActionState(action, initialState, permalink?) wraps your action and returns a tuple: [state, formAction, isPending]. You pass formAction to the form, React calls your action on submit, and whatever the action returns becomes the new state. The detail that trips up everyone converting an existing action: your function now receives the previous state first and the form data second, as (prevState, formData).

I’m writing the action as a Next.js Server Action from the start, because that is what makes the no-JavaScript fallback possible later. The hooks don’t care: in a Vite app the same function would call your API with fetch and everything else in this post works the same.

// app/blog/[slug]/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { saveComment } from "@/lib/comments";

export type CommentState =
  | { status: "idle" }
  | { status: "success"; message: string }
  | {
      status: "error";
      errors: { author?: string; body?: string };
      values: { author: string; body: string };
    };

export async function addComment(
  _prev: CommentState,
  formData: FormData,
): Promise<CommentState> {
  const slug = String(formData.get("slug") ?? "");
  const author = String(formData.get("author") ?? "").trim();
  const body = String(formData.get("body") ?? "").trim();

  const errors: { author?: string; body?: string } = {};
  if (!author) errors.author = "Add your name.";
  if (body.length < 3) errors.body = "Write at least 3 characters.";

  if (errors.author || errors.body) {
    // Expected failure: return it, don't throw it
    return { status: "error", errors, values: { author, body } };
  }

  await saveComment({ slug, author, body });
  revalidatePath(`/blog/${slug}`);
  return { status: "success", message: "Thanks, your comment is live." };
}

The state is a discriminated union, so the component can only read errors after checking status === "error". Now the form that uses it:

// app/blog/[slug]/comment-form.tsx
"use client";

import { useActionState } from "react";
import { addComment, type CommentState } from "./actions";
import { SubmitButton } from "./submit-button";

const initialState: CommentState = { status: "idle" };

export function CommentForm({ slug }: { slug: string }) {
  const [state, formAction, isPending] = useActionState(
    addComment,
    initialState,
  );
  const errors = state.status === "error" ? state.errors : {};
  const values = state.status === "error" ? state.values : null;

  return (
    <form action={formAction} aria-busy={isPending}>
      <input type="hidden" name="slug" value={slug} />

      <label htmlFor="author">Name</label>
      <input id="author" name="author" defaultValue={values?.author} />
      {errors.author && <p role="alert">{errors.author}</p>}

      <label htmlFor="body">Comment</label>
      <textarea id="body" name="body" defaultValue={values?.body} />
      {errors.body && <p role="alert">{errors.body}</p>}

      <SubmitButton>Post comment</SubmitButton>
      {state.status === "success" && (
        <p role="status">{state.message}</p>
      )}
    </form>
  );
}

A few behaviours worth knowing before you ship this:

  • Return expected errors, throw unexpected ones. If the action throws, React rethrows the error from the hook to the nearest error boundary and skips every submission still queued behind it. Validation failures belong in state.
  • Submissions run one at a time. React queues calls to formAction and runs them in order, each receiving the previous result as prevState. Two fast clicks mean two comments, so use isPending (or the button below) to block the second one.
  • The return type must match initialState. Typing the action’s return as Promise<CommentState> keeps TypeScript from inferring a narrower state from your initial value.

Why React 19 clears your form after a failed submit

This one looks like a bug the first time you see it. When a form submits through the action prop, React requests a form reset as the action starts and applies it when the action’s transition commits. It has no idea your returned state says “error”. I tested it: type a name, submit a comment that fails validation, and the error message appears while the name field goes blank.

That is why addComment returns values and the inputs use defaultValue={values?.author}. A reset restores each field to its default value, and the new default lands in the same commit, so the user’s text survives the reset. On success, values is gone and the form clears, which is exactly what you want after posting a comment.

The other ways out:

  • Controlled inputs (value plus onChange) aren’t affected by the reset at all. Fine for a field you already control, overkill for a whole form.
  • Calling the action yourself. The automatic reset only happens through the action prop. If you handle onSubmit and call formAction inside startTransition, nothing resets, and you can reset on your terms with requestFormReset(form) from react-dom (it must also run inside a transition).
  • A key on the form component remounts it with fresh state when you want a hard reset.

useFormStatus: a submit button that knows it’s pending

useFormStatus() takes no arguments and returns { pending, data, method, action } for the form it sits inside. It comes from react-dom, not react, and it exists so a reusable child can read the form’s status without props:

// app/blog/[slug]/submit-button.tsx
"use client";

import type { ReactNode } from "react";
import { useFormStatus } from "react-dom";

export function SubmitButton({ children }: { children: ReactNode }) {
  const { pending } = useFormStatus();

  return (
    <button type="submit" disabled={pending} aria-disabled={pending}>
      {pending ? "Posting…" : children}
    </button>
  );
}

The rule that matters: it must be called from a component rendered inside the <form>. It only reports on a parent form, never on a form rendered by the same component. Call it next to the <form> in CommentForm and pending is false forever; I checked, and it never flips even while the action runs.

So when do you use which? isPending belongs to the component that owns the action, and it also works for actions triggered outside a form. useFormStatus belongs in design-system pieces like buttons and spinners that should work in any form without wiring. When pending is true, data holds the FormData being submitted, which becomes useful in a minute.

useOptimistic: show the comment before the server answers

Waiting for the round trip before a comment appears feels slow. useOptimistic(value, reducer?) returns [optimisticState, setOptimistic]: the optimistic state equals value unless an action is pending, in which case it’s whatever your updates produced. To show the new comment in the list, the list and the form need to share the hook:

// app/blog/[slug]/comments.tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { addComment, type CommentState } from "./actions";
import type { Comment } from "@/lib/comments";

type Shown = Comment & { sending?: boolean };

export function Comments({
  slug,
  comments,
}: {
  slug: string;
  comments: Comment[];
}) {
  const [shown, addShown] = useOptimistic<Shown[], Shown>(
    comments,
    (current, next) => [...current, next],
  );

  const [state, formAction] = useActionState(
    async (prev: CommentState, formData: FormData) => {
      addShown({
        id: crypto.randomUUID(),
        author: String(formData.get("author")),
        body: String(formData.get("body")),
        sending: true,
      });
      return addComment(prev, formData);
    },
    { status: "idle" },
  );

  return (
    <section>
      <ul>
        {shown.map((c) => (
          <li key={c.id} className={c.sending ? "opacity-50" : ""}>
            <strong>{c.author}</strong> {c.body}
          </li>
        ))}
      </ul>
      <form action={formAction}>{/* fields from CommentForm */}</form>
      {state.status === "error" && <p role="alert">Fix the errors.</p>}
    </section>
  );
}

Pass explicit generics here. Without them TypeScript infers the state from comments and rejects the extra sending flag.

Rollback is the part you no longer write. When the action finishes, React renders with whatever comments is at that point. If the save succeeded, revalidatePath makes Next.js send the re-rendered page in the same response, so the real comment replaces the faded one. If it failed, comments never changed and the optimistic one just disappears. No undo code, no stale temporary IDs.

Two rules. Call the setter inside an action or transition: outside one, React logs “An optimistic state update occurred outside a transition or action” and the value flashes and reverts immediately. And don’t show what you know will fail: a comment that fails validation appears for a moment, then vanishes. Add required and minLength to the fields so the browser blocks obvious cases before your action runs, and keep the server check as the authority.

Progressive enhancement with a Next.js Server Action

If the client/server split in the App Router is new to you, my post on Server Components in practice covers the "use client" boundary; here we only need the form side. Because CommentForm passes the Server Action itself to useActionState, React can render the form as a real HTML form: method="POST", multipart/form-data, and hidden $ACTION_* fields that identify the action. I curl-tested a production build with no JavaScript at all: an invalid submit came back as a full page with the error message and the typed values filled in, and a valid one rendered the success message.

That fallback has two requirements: initialState must be serializable (plain objects, strings, numbers), and the function passed to useActionState must be the Server Action, not a wrapper around it.

And that is the catch with the optimistic version above. The inline wrapper is a client function, so React has nothing to post to. The server-rendered form gets action="javascript:throw new Error('React form unexpectedly submitted.')" plus a small script that queues submits until hydration and then replays them. Slow JavaScript is covered; no JavaScript isn’t.

If you want both, skip the wrapper. Keep CommentForm exactly as it is and render the in-flight comment from useFormStatus().data inside it:

// app/blog/[slug]/pending-comment.tsx
"use client";

import { useFormStatus } from "react-dom";

export function PendingComment() {
  const { pending, data } = useFormStatus();
  if (!pending) return null;

  return (
    <p className="opacity-50">
      <strong>{String(data.get("author"))}</strong> {String(data.get("body"))}
    </p>
  );
}

Drop <PendingComment /> anywhere inside the form. The FormStatus type is a discriminated union, so after the pending check data is typed as FormData rather than FormData | null. You lose the preview landing inside the list; you keep a form that works on a broken connection. On a public comment form I take that trade.

One security note the Next.js forms guide repeats for good reason: a Server Action is reachable with a plain POST, exactly like the curl test above. Validate and authorize inside every action, even when the form only renders for signed-in users.

Return the errors you expect, throw only the ones you don’t.

Common mistakes with React 19 forms

  • Calling useFormStatus in the component that renders the form. It only sees parent forms, so pending stays false. Move it into a child.
  • Forgetting the prevState parameter. An action written as (formData) => … receives the state object first, and the first formData.get() throws a TypeError.
  • Still importing useFormState. It was renamed to useActionState and moved from react-dom to react. The old name still runs in 19.3 but logs “ReactDOM.useFormState has been renamed to React.useActionState” in development.
  • Throwing validation errors. That turns a too-short comment into an error boundary and cancels queued submits. Return them as state.
  • Calling formAction from a click handler without startTransition. React logs that the function was called outside a transition, and isPending never becomes true.
  • Ignoring pending state. Without a disabled button, a double click queues two submits and both run. Use isPending or useFormStatus.
  • Wrapping a Server Action “just to log something”. Any client wrapper drops the no-JavaScript fallback. Log on the server instead.

Frequently Asked Questions

What replaced useFormState in React 19?

useActionState, imported from react instead of react-dom, replaced useFormState. It takes the same arguments (an action, an initial state and an optional permalink) and returns [state, formAction, isPending]. The old useFormState still exists in React 19.3 but logs a deprecation warning in development, so rename it when you touch the file.

Why is useFormStatus pending always false?

useFormStatus only reports on a parent <form>, so it returns pending: false when it’s called in the same component that renders the form, or anywhere outside a form. Move the call into a child component, such as a submit button, that is rendered inside the <form>. If you need the status in the component that owns the form, read isPending from useActionState instead.

Can I use useActionState without Next.js?

Yes. useActionState, useFormStatus and useOptimistic are React 19 APIs and work in any React 19 app, including Vite, with any async function as the action. What needs a framework is Server Functions: the no-JavaScript fallback and the permalink argument only apply when the action runs on the server, as with Next.js Server Actions.

How do I stop a React 19 form from resetting after submit?

A React 19 form resets its uncontrolled fields whenever an action passed to the action prop finishes, even if the action returned an error. To keep the user’s input, return the submitted values from the action and bind them with defaultValue, use controlled inputs, or call the action from onSubmit inside startTransition, which skips the automatic reset.

Putting it into practice

Start with the plain version: a Server Action that returns typed state, useActionState in the form, and a useFormStatus button. Echo values back so failed submits don’t wipe input, and add useOptimistic only where the wait is noticeable and losing the no-JavaScript fallback is acceptable. The API references for useActionState and useOptimistic are short and worth a read. For what happens to the cache after revalidatePath, see my Next.js performance guide, and the frontend category has more React guides.

Thanks for reading!