Skip to the solution
HardFrontend6 min read

What does the useActionState hook do in React 19?

947 views

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.

  • react
  • react-19
  • hooks
  • forms

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read · 8 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactA signup form: previous state threaded in, errors returned rather than thrown
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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 76 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

What problem does `useSyncExternalStore` solve?

Continue