Skip to content
Back to blog

React 19 forms: Actions, useActionState and useOptimistic

Every React form I wrote before version 19 had the same boilerplate: an isPending flag, an error state, a try/catch, and a finally that reset the flag. It worked, and it was the same forty lines every time.

React 19 moves that pattern into the framework. You pass a function to a form, and React tracks the pending state, errors and updates for you. Here are the pieces and how they fit together.

Actions

An Action is a function you pass to a form’s action prop. When the form submits, React calls it with the form’s FormData, and it can be async.

NewsletterForm.tsx
async function subscribe(formData: FormData) { 
  const email = formData.get("email") as string;
  await fetch("/api/subscribe", { method: "POST", body: JSON.stringify({ email }) });
}

export default function NewsletterForm() {
  return (
    <form action={subscribe}>
      <input type="email" name="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

There is no onSubmit, no preventDefault and no controlled input. The browser’s own form behavior does the work, and React resets uncontrolled fields after the Action succeeds.

Actions run inside a transition, so the UI stays responsive while the request is in flight.

useFormStatus

A button deep inside a form often needs to know whether the form is submitting, to disable itself. useFormStatus reads that from the nearest parent form, so you do not have to pass a prop down.

SubmitButton.tsx
"use client";

import { useFormStatus } from "react-dom";

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

  return (
    <button type="submit" disabled={pending}>
      {pending ? "Sending..." : children}
    </button>
  );
}

It must be used inside a child of the form. Calling it in the same component that renders the <form> returns nothing useful, which is a common first-day mistake.

useActionState

Most forms also need a result: an error message, or a success flag. useActionState wraps an Action and gives you the last returned value plus a pending flag.

ContactForm.tsx
"use client";

import { useActionState } from "react";

type State = { error?: string; ok?: boolean };

async function send(previous: State, formData: FormData): Promise<State> { 
  const message = String(formData.get("message") ?? "").trim();
  if (message.length < 10) return { error: "Please write at least 10 characters." };

  const res = await fetch("/api/contact", { method: "POST", body: formData });
  return res.ok ? { ok: true } : { error: "Something went wrong. Try again." };
}

export default function ContactForm() {
  const [state, formAction, isPending] = useActionState(send, {}); 

  return (
    <form action={formAction}>
      <textarea name="message" required />
      <button type="submit" disabled={isPending}>Send</button>
      <p role="status" aria-live="polite">
        {state.error ?? (state.ok && "Message sent.")}
      </p>
    </form>
  );
}

The Action now takes the previous state as its first argument and returns the next one. The hand-written isPending, error and try/catch state are gone. I kept an aria-live region, so screen readers announce the result.

useOptimistic

Optimistic UI means showing the result right away and fixing it only if the request fails. Before, that meant juggling a temporary copy of state. useOptimistic does it for you: the optimistic value is shown while an Action runs, then replaced by the real state when it finishes.

Comments.tsx
"use client";

import { useOptimistic } from "react";

type Comment = { id: string; text: string; sending?: boolean };

export default function Comments({
  comments,
  addComment,
}: {
  comments: Comment[];
  addComment: (text: string) => Promise<void>;
}) {
  const [optimistic, addOptimistic] = useOptimistic( 
    comments,
    (current, text: string) => [...current, { id: crypto.randomUUID(), text, sending: true }],
  );

  async function submit(formData: FormData) {
    const text = String(formData.get("text"));
    addOptimistic(text); 
    await addComment(text);
  }

  return (
    <>
      <ul>
        {optimistic.map((c) => (
          <li key={c.id} style={{ opacity: c.sending ? 0.5 : 1 }}>{c.text}</li>
        ))}
      </ul>
      <form action={submit}>
        <input name="text" required />
        <button type="submit">Post</button>
      </form>
    </>
  );
}

If addComment throws, the optimistic comment disappears when the Action ends, because React falls back to the real comments. You get a rollback without writing one.

Server Actions

In a framework that supports Server Components, an Action can run on the server. Mark the function with "use server" and the framework turns the call into a network request for you.

actions.ts
"use server"; 

import { db } from "@/lib/db";

export async function addComment(text: string) {
  if (text.trim().length === 0) throw new Error("Empty comment");
  await db.comment.create({ data: { text } });
}
Life of a form submission
  1. User submitsform action called with FormData
  2. Optimistic updateuseOptimistic shows the result now
  3. Action runsisPending is true
  4. Done or failedreal state replaces the guess

React handles the pending state and the rollback. You write the Action.

Where React Hook Form still fits

These APIs are great for simple forms and for submitting to a server. They do not give you field-level validation as the user types, dependent fields or complex arrays of inputs. For those, a library such as React Hook Form is still the better choice, and it works fine alongside Actions.

My split: Actions for forms with a few fields and a server round trip, a form library when validation is the hard part.

Interview answer in 30 seconds

“In React 19, a form’s action prop takes a function that receives FormData and can be async, running inside a transition. useFormStatus lets a child read the form’s pending state. useActionState wraps an Action and returns the last result, the action to pass to the form and a pending flag. useOptimistic shows an immediate guess and rolls back automatically if the Action fails. Server Actions are the same idea running on the server, so I validate and authorize inside them.”

Key takeaways

  • Pass a function to <form action> and drop onSubmit, preventDefault and manual pending flags.
  • useFormStatus must be called in a child of the form.
  • useActionState returns [state, formAction, isPending].
  • useOptimistic gives instant UI and an automatic rollback on failure.
  • Server Actions are public endpoints, so validate and authorize inside them.
  • Keep a form library when validation and field logic are the hard part.