Form Actions & useActionState

React · Lesson 14 of 29 7 min read Updated

In the previous lesson you handled forms with onSubmit, e.preventDefault() and several pieces of state for "saving", "error" and "done". React 19 added Actions, which remove most of that boilerplate. You pass a function, even an async one, straight to a form's action prop, and React handles the pending state, errors and form reset for you.

In this lesson you will write form actions, show a pending button with useFormStatus, keep the result of each submission with useActionState, and make the interface feel instant with useOptimistic.

A function as the form action

In plain HTML, <form action="/subscribe"> sends the form to a URL. In React 19 you can pass a function instead. React calls it with a FormData object when the form is submitted. You do not need preventDefault(), and after an action finishes successfully React resets the form's uncontrolled fields automatically.

React JSXTry it yourself
import { createRoot } from "react-dom/client";
import { useFormStatus } from "react-dom";

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Subscribing..." : "Subscribe"}</button>;
}

function Newsletter() {
  async function subscribe(formData) {
    const email = formData.get("email");
    await wait(1000); // pretend to call the server
    console.log(`Subscribed ${email} to the Lucknow food newsletter`);
  }

  return (
    <form action={subscribe}>
      <input name="email" type="email" placeholder="you@example.com" required />
      <SubmitButton />
    </form>
  );
}

createRoot(document.getElementById("root")).render(<Newsletter />);

Entering "rohan@example.com" and pressing "Subscribe" disables the button for one second, shows "Subscribing...", clears the input, and prints:

Output
Subscribed rohan@example.com to the Lucknow food newsletter

React runs the action inside a transition, a lower-priority update that keeps the page responsive while the async work runs. The useFormStatus hook, imported from react-dom, reads the status of the form it is inside: pending is true while the action runs, and data holds the submitted FormData. It must be called in a component rendered inside the <form>; that is why the button is its own component.

useActionState: remember the result

Most forms need to show something after submitting: a success message, a validation error or a new total. useActionState connects an action to a piece of state.

React JSX
const [state, formAction, isPending] = useActionState(action, initialState);
  • Your action(previousState, formData) receives the previous state first and the form data second, and whatever it returns becomes the new state.
  • formAction is the function you pass to <form action={...}>.
  • isPending is true while the action is running.
React JSXTry it yourself
import { useActionState } from "react";
import { createRoot } from "react-dom/client";

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const coupons = { DIWALI10: 10, STUDENT15: 15 };

async function applyCoupon(previous, formData) {
  const code = String(formData.get("code")).trim().toUpperCase();
  await wait(700); // pretend to check with the server
  if (!(code in coupons)) {
    return { ...previous, error: `"${code}" is not a valid coupon.` };
  }
  const discount = coupons[code];
  return { total: Math.round(previous.base * (1 - discount / 100)), base: previous.base, error: "", applied: code };
}

function Checkout() {
  const [state, formAction, isPending] = useActionState(applyCoupon, {
    base: 2400,
    total: 2400,
    error: "",
    applied: "",
  });

  return (
    <form action={formAction}>
      <p>Cricket kit total: ₹{state.total.toLocaleString("en-IN")}</p>
      {state.applied && <p style={{ color: "green" }}>{state.applied} applied</p>}
      <input name="code" placeholder="Coupon code" />
      <button disabled={isPending}>{isPending ? "Checking..." : "Apply"}</button>
      {state.error && <p style={{ color: "crimson" }}>{state.error}</p>}
    </form>
  );
}

createRoot(document.getElementById("root")).render(<Checkout />);

Try "HOLI50" first, then "student15". The wrong code shows an error; the correct one changes the total to ₹2,040. Notice that expected problems, such as a bad coupon, are returned as part of the state rather than thrown. If an action throws, React treats it as an unexpected error and shows the nearest error boundary instead.

useActionState runs submissions one after another: if the user clicks twice quickly, the second call receives the result of the first as previous. When you call the returned formAction yourself instead of passing it to a form, wrap the call in startTransition from react so React can track it.

useOptimistic: show the result before the server answers

Some actions almost always succeed, such as liking a post or sending a chat message. Waiting a second for the server makes the app feel slow. An optimistic update shows the expected result immediately and corrects it if the server fails.

useOptimistic(value, reducer) returns [optimisticValue, addOptimistic]. While an action is running, optimisticValue includes your temporary changes; when the action ends, it goes back to the real value.

React JSXTry it yourself
import { useState, useOptimistic, startTransition } from "react";
import { createRoot } from "react-dom/client";

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function saveMessage(text) {
  await wait(1200);
  if (text.toLowerCase().includes("fail")) throw new Error("Network error, message not sent");
  return { id: crypto.randomUUID(), text };
}

function ClassGroupChat() {
  const [messages, setMessages] = useState([{ id: "m1", text: "Maths test moved to Friday" }]);
  const [error, setError] = useState("");
  const [optimisticMessages, addOptimistic] = useOptimistic(messages, (current, text) => [
    ...current,
    { id: "temp", text, sending: true },
  ]);

  async function sendAction(formData) {
    const text = formData.get("text");
    setError("");
    addOptimistic(text); // appears immediately
    try {
      const saved = await saveMessage(text);
      startTransition(() => setMessages((m) => [...m, saved]));
    } catch (err) {
      setError(err.message); // the optimistic message disappears when the action ends
    }
  }

  return (
    <>
      <ul>
        {optimisticMessages.map((m) => (
          <li key={m.id} style={{ opacity: m.sending ? 0.5 : 1 }}>
            {m.text} {m.sending && <small>(sending...)</small>}
          </li>
        ))}
      </ul>
      <form action={sendAction}>
        <input name="text" placeholder="Type a message" required />
        <button>Send</button>
      </form>
      {error && <p style={{ color: "crimson" }}>{error}</p>}
    </>
  );
}

createRoot(document.getElementById("root")).render(<ClassGroupChat />);

Send "See you at 9" and it appears at once in grey with "(sending...)", then turns solid. Send a message containing the word "fail" and it appears, then vanishes with an error, because the real messages never received it. The setter call after await is wrapped in startTransition so React applies it together with the end of the action, rather than as a separate update.

addOptimistic must be called inside an action or transition. Called anywhere else, React logs an error and the optimistic value disappears straight away.

Actions on the server

In frameworks such as Next.js, an action can run on the server. A function marked with the "use server" directive becomes a Server Function that the form calls over the network, while you still write <form action={createOrder}>:

React JSX
// app/actions.js (Next.js)
"use server";

export async function createOrder(formData) {
  const item = formData.get("item");
  // save to the database here; this code never reaches the browser
}

You will meet this again in Server Components & Next.js.

Common mistakes

  • Calling useFormStatus in the form component itself. It only sees a parent <form>; put it in a child such as SubmitButton.
  • Wrong argument order. With useActionState, the action receives (previousState, formData), not (formData).
  • Throwing for validation errors. Return error messages as state; throwing triggers an error boundary.
  • Expecting controlled inputs to reset. Automatic reset applies to uncontrolled fields; reset your own state for controlled ones.
  • Optimistic updates outside an action. Call addOptimistic inside the form action or a startTransition callback.

Key points

  • In React 19 a <form action={fn}> calls fn(formData), can be async, and resets uncontrolled fields on success.
  • useFormStatus() from react-dom gives pending and data to components inside the form.
  • useActionState(action, initial) returns [state, formAction, isPending]; the action gets (previousState, formData).
  • Return expected errors in state; thrown errors go to error boundaries.
  • useOptimistic shows a temporary result during an action and falls back to the real value afterwards.

Exercise

Build a "Book a demo class" form with fields name and course (a select with "Python", "React" and "SQL"). Use useActionState with an async action that waits 800 ms, rejects names shorter than 3 characters by returning an error, and otherwise returns a confirmation such as "Seat booked for Priya in React". Show the confirmation or the error, and disable the button while pending.

Show solution
React JSX
import { useActionState } from "react";
import { createRoot } from "react-dom/client";

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function book(previous, formData) {
  const name = String(formData.get("name")).trim();
  const course = formData.get("course");
  await wait(800);
  if (name.length < 3) {
    return { ok: false, message: "Please enter at least 3 letters of your name." };
  }
  return { ok: true, message: `Seat booked for ${name} in ${course}` };
}

function DemoBooking() {
  const [result, formAction, isPending] = useActionState(book, null);

  return (
    <form action={formAction}>
      <input name="name" placeholder="Your name" />
      <select name="course" defaultValue="React">
        <option>Python</option>
        <option>React</option>
        <option>SQL</option>
      </select>
      <button disabled={isPending}>{isPending ? "Booking..." : "Book"}</button>
      {result && <p style={{ color: result.ok ? "green" : "crimson" }}>{result.message}</p>}
    </form>
  );
}

createRoot(document.getElementById("root")).render(<DemoBooking />);

The action returns a result object in both cases, so the component simply renders whatever the latest state is. isPending disables the button during the wait.

Quick Quiz

1. What does a function passed to <form action={...}> receive in React 19?

2. Where must useFormStatus be called?

3. With useActionState, what are the action's arguments?

4. What happens to an optimistic value when its action fails?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call