webdev.complete
🚀 React 19 Superpowers
⚛️React
Lesson 88 of 117
25 min

Actions & useActionState

Form actions. Pending state. Server actions overview.

React 19 made a quiet but huge change: <form>got first-class superpowers. You can now pass a function to the form'saction prop, and React handles submission, FormData, pending state, and even progressive enhancement for you. Combined withuseActionState and useFormStatus, it's the cleanest form pattern React has ever shipped.

The old way (still works)

tsx
function ContactForm() {
  const [name, setName] = useState("");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setPending(true);
    try { await saveContact({ name }); }
    catch (err) { setError(err.message); }
    finally { setPending(false); }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button disabled={pending}>{pending ? "..." : "Save"}</button>
      {error && <p>{error}</p>}
    </form>
  );
}

Three pieces of state for one form. Multiply by every field. Multiply by every form in your app. React 19 trims this down dramatically.

The new way: form action props

tsx
function ContactForm() {
  async function save(formData) {
    const name = formData.get("name");
    await saveContact({ name });
  }

  return (
    <form action={save}>
      <input name="name" />
      <button type="submit">Save</button>
    </form>
  );
}

Pass a function to action. React intercepts submit, calls your function with the FormData, and resets the form on success. Notice: no useState for the inputs, no onSubmit, no preventDefault. The form does the work.

Progressive enhancement comes free
Because action is a real HTML form attribute, the form works without JavaScript: it does a regular browser POST. In Next.js with server actions, the same code submits via a network request to the server and re-renders. The user without JS still gets a working form.

useActionState: state, error, pending in one hook

For forms that need to display a result (success message, server error, validation), useActionState is the bigger gun:

tsx
import { useActionState } from "react";

async function submitAction(prevState, formData) {
  const name = formData.get("name");
  if (!name) return { error: "Name required" };

  try {
    await saveContact({ name });
    return { success: true, name };
  } catch (e) {
    return { error: e.message };
  }
}

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitAction, null);

  return (
    <form action={formAction}>
      <input name="name" />
      <button disabled={isPending}>{isPending ? "Saving..." : "Save"}</button>
      {state?.error && <p style={{ color: "red" }}>{state.error}</p>}
      {state?.success && <p>Saved {state.name}!</p>}
    </form>
  );
}

The action signature is (prevState, formData) => nextState. Whatever you return becomes the new state. isPending flips to true while the action runs and back when it resolves. One hook, all three concerns.

useFormStatus: pending state from descendants

Sometimes a button or a spinner deep inside the form needs to know if the form is submitting, but it doesn't own the form's state. useFormStatusreads the nearest parent form's status.

tsx
import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting..." : "Submit"}
    </button>
  );
}

// Usage, SubmitButton doesn&apos;t need any props
function Form() {
  return (
    <form action={save}>
      <input name="title" />
      <SubmitButton />
    </form>
  );
}

This is the key to reusable submit buttons. SubmitButton doesn't know or care which form it's in. It just reads the ambient form status.

FormData: the underused web standard

React 19 actions work natively with FormData, a long existing browser API. Every <input name="..."> contributes a field. You read them out with formData.get:

tsx
async function save(formData) {
  const name = formData.get("name");       // string
  const email = formData.get("email");
  const file = formData.get("avatar");     // File object for <input type="file">
  const tags = formData.getAll("tags");    // array for multiple inputs
  const isAdmin = formData.has("admin");   // checkbox presence

  await api.save({ name, email, tags, isAdmin });
}

Validation lives wherever you want. Use Zod, do it by hand, run it on the server. The point is: the form is just data, and the data is just FormData.

Server actions only feel magical in a framework
Without a framework (Next.js, Remix), action={fn}still works for client-side forms. But the "works without JS" + "runs on the server" superpowers come from the framework treating your "use server" functions as endpoints. We cover that in the Next.js chapter.

Try it: a form with state and pending

Three actions running through one form. Try invalid inputs to see errors flow through the returned state. Try the "throw" button to see error handling.

import { useActionState } from "react";
import { useFormStatus } from "react-dom";

async function fakeSave(prev, formData) {
  const name = (formData.get("name") || "").toString().trim();
  await new Promise((r) => setTimeout(r, 600));
  if (!name) return { error: "Name is required" };
  if (name === "throw") return { error: "Something broke on the server" };
  return { success: `Saved "${name}"` };
}

function Submit({ children }) {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Saving..." : children}</button>;
}

export default function App() {
  const [state, formAction, isPending] = useActionState(fakeSave, null);

  return (
    <form
      action={formAction}
      style={{ padding: 24, fontFamily: "system-ui", maxWidth: 360 }}
    >
      <h3>React 19 form action</h3>
      <input
        name="name"
        placeholder="Try empty, &quot;throw&quot;, or any name"
        style={{ padding: 6, width: "100%", marginBottom: 8 }}
      />
      <Submit>Save</Submit>
      <p style={{ fontSize: 12, color: "#6b7280" }}>
        isPending: {String(isPending)}
      </p>
      {state?.error && (
        <p style={{ color: "#dc2626" }}>Error: {state.error}</p>
      )}
      {state?.success && (
        <p style={{ color: "#16a34a" }}>{state.success}</p>
      )}
    </form>
  );
}

Quiz

Quiz1 / 3

What does passing a function to <form action={fn}> do?

Recap

  • <form action={fn}> turns a function into a submit handler that gets FormData.
  • useActionState returns [state, formAction, isPending]. Use it when you need a typed result back from the action.
  • useFormStatusexposes the parent form's pending state to descendants. Reusable submit buttons.
  • Forms work as native HTML forms when JS is disabled.
  • You write less code. The form is just data. The browser is your ally.