Skip to solution
hardFrontend

What does the `useActionState` hook do in React 19?

946 views
01

Understand the problem

Question presented to candidate: "What does useActionState give you that a useState plus a loading flag does not?"

What a strong answer should cover:

  • It returns a three-element tuple: [state, formAction, isPending].
  • It is shaped like a reducer whose action function may be async: it receives the previous state and the FormData, and whatever it returns becomes the new state.
  • That previous-state argument is the real difference from useState — retry counts, accumulated errors, and "last submitted values" come for free.
  • isPending is React's, not yours, because the call runs inside a transition. No setSubmitting(true)/finally pair.
  • The idiomatic error strategy is to catch inside the action and return the error as state, rather than throwing to an error boundary — a bad email should not blank the page.
  • The formAction it returns is passed to <form action={...}>, or to a button's formAction.
  • With a framework it supports progressive enhancement: the form posts before hydration, and the returned state is available afterwards.
  • It replaces the React 18 canary name useFormState, and lives in react, not react-dom.

Clarifying questions expected:

  • "Does the result need to survive between submissions?" — that is what the state argument is for.
  • "Is this validation, which should be state, or a genuine crash, which should throw?"

Code / implementation expected: Optional. Showing the (prevState, formData) => newState signature is the substance.

reactreact-19hooksforms
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes what Actions are. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

A signup form: previous state threaded in, errors returned rather than thrown
Run Playground
import { useActionState } from "react";

// A fake server that rejects anything without an @, and is slow enough to see.
async function signUp(email) {
  await new Promise((r) => setTimeout(r, 700));
  if (!String(email).includes("@")) throw new Error("That does not look like an email");
  return { id: Math.floor(Math.random() * 1000), email };
}

export default function App() {
  const [state, formAction, isPending] = useActionState(
    // Reducer signature: PREVIOUS STATE first, FormData second.
    async (previous, formData) => {
      const email = formData.get("email");
      try {
        const user = await signUp(email);
        return { user, error: null, attempts: previous.attempts + 1, lastEmail: "" };
      } catch (e) {
        // RETURNED, not thrown — throwing would hit an error boundary and
        // blank the page over a typo. Keeping lastEmail lets the form
        // re-render with what the user actually wrote.
        return { user: previous.user, error: e.message, attempts: previous.attempts + 1, lastEmail: email };
      }
    },
    { user: null, error: null, attempts: 0, lastEmail: "" },
  );

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 480 }}>
      <form action={formAction} style={{ border: "1px solid #ddd", borderRadius: 8, padding: 14 }}>
        <input
          name="email"
          // Fed back from state so a failed attempt does not lose the input,
          // even though React resets the form after the action resolves.
          defaultValue={state.lastEmail}
          placeholder="try 'nope', then 'ada@example.com'"
          style={{ width: "100%", marginBottom: 8 }}
        />
        <button type="submit" disabled={isPending}>
          {isPending ? "signing up…" : "sign up"}
        </button>

        <div style={{ marginTop: 10, fontSize: 13 }}>
          {/* During the pending render the OLD state is still here — which is
              why the previous error stays visible while the retry is in flight. */}
          {state.error && <div style={{ color: "#a33" }}>⚠ {state.error}</div>}
          {state.user && <div style={{ color: "#161" }}>✓ created #{state.user.id} for {state.user.email}</div>}
          <div style={{ color: "#666" }}>
            attempts: <strong>{state.attempts}</strong>
            {isPending && " · request in flight"}
          </div>
        </div>
      </form>

      <p style={{ fontSize: 13, color: "#666" }}>
        The attempts counter is computed from <code>previous.attempts</code> —
        that previous-state argument is the difference from <code>useState</code>.
        Submit a bad value, then a good one: notice the old error stays on screen
        during the pending render, because state does not change until the
        action returns.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 76 of 119 decoded in the React.js track. One more won't hurt.

Back to track