Skip to solution
mediumFrontend

useFormStatus & useActionState — modern form handling in React 19

360 views
01

Understand the problem

Question presented to candidate: "Build me a form in React 19 with validation errors, a disabled button while saving, and a shared submit component. Which hooks, and where does each go?"

What a strong answer should cover:

  • The two hooks answer different questions from different positions in the tree — that is the whole selection rule.
  • useActionState sits where the action is defined: it owns the returned state (result and validation errors) and gives you isPending there.
  • useFormStatus sits inside the form, in a component that was passed nothing: it reads { pending, data, method, action } from the nearest form above.
  • They compose — one useActionState at the form level for the result, and any number of descendants reading useFormStatus.
  • Package matters: useActionState from react, useFormStatus from react-dom.
  • Most fields should be uncontrolled; control only the ones needing per-keystroke behaviour.
  • Errors are returned from the action as state, not thrown, so the form stays on screen — and the submitted values come back with them so nothing is lost.
  • Remember React resets an uncontrolled form on success, so a field that must survive needs its value fed back or controlled.

Clarifying questions expected:

  • "Is the submit button shared across forms, or specific to this one?" — that decides whether useFormStatus earns its place.
  • "Do any fields need live validation?" — those stay controlled.

Code / implementation expected: Yes — a small end-to-end form is the natural answer here.

formsreact-19hooks
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 Actions. Difficulty: Medium

How to read this doc: this is the practical assembly doc. The mechanics of each hook live in <a href="PASTE_USE_ACTION_STATE_U

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

A complete form: returned errors, preserved input, and a shared submit button
Run Playground
import { useActionState, useState } from "react";
import { useFormStatus } from "react-dom";

const save = async (email) => {
  await new Promise((r) => setTimeout(r, 800));
  if (email.endsWith("@taken.com")) throw new Error("That address is already registered");
  return email;
};

// Shared across the app. Reads the nearest form above it — no props.
function SubmitButton({ children }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} style={{ marginRight: 8 }}>
      {pending ? "Saving…" : children}
    </button>
  );
}

// Also inside the form, so it can dim the fields while saving.
function Fieldset({ children }) {
  const { pending } = useFormStatus();
  return <div style={{ opacity: pending ? 0.5 : 1, transition: "opacity 150ms" }}>{children}</div>;
}

export default function App() {
  const [state, formAction] = useActionState(
    async (previous, formData) => {
      const email = String(formData.get("email") || "");
      const note = String(formData.get("note") || "");

      // Validation failures are RETURNED, so the form survives. The submitted
      // values come back too — React clears an uncontrolled form on success,
      // and feeding these into defaultValue is what preserves them on failure.
      if (!email.includes("@")) return { ...previous, error: "Enter a valid email", email, note };
      try {
        const saved = await save(email);
        return { saved, error: null, email: "", note: "", count: (previous.count || 0) + 1 };
      } catch (e) {
        return { ...previous, error: e.message, email, note };
      }
    },
    { saved: null, error: null, email: "", note: "", count: 0 },
  );

  // A CONTROLLED field, because it needs a live character counter — the one
  // reason left to hold a field in state.
  const [note, setNote] = useState("");

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 500 }}>
      <form action={formAction} style={{ border: "1px solid #ddd", borderRadius: 8, padding: 14 }}>
        <Fieldset>
          <label style={{ fontSize: 13 }}>Email (uncontrolled)</label>
          <input
            name="email"
            defaultValue={state.email}
            placeholder="try 'nope', then 'a@taken.com', then 'a@b.com'"
            style={{ width: "100%", margin: "4px 0 10px" }}
          />

          <label style={{ fontSize: 13 }}>Note (controlled — needs a live count)</label>
          <input
            name="note"
            value={note}
            onChange={(e) => setNote(e.target.value.slice(0, 40))}
            style={{ width: "100%", margin: "4px 0 2px" }}
          />
          <div style={{ fontSize: 12, color: note.length > 30 ? "#a33" : "#666" }}>
            {note.length}/40
          </div>
        </Fieldset>

        <div style={{ marginTop: 10 }}>
          <SubmitButton>Save</SubmitButton>
        </div>

        <div style={{ marginTop: 10, fontSize: 13 }}>
          {state.error && <div role="alert" style={{ color: "#a33" }}>⚠ {state.error}</div>}
          {state.saved && <div style={{ color: "#161" }}>✓ saved {state.saved}</div>}
          <div style={{ color: "#666" }}>successful saves: {state.count || 0}</div>
        </div>
      </form>

      <p style={{ fontSize: 13, color: "#666" }}>
        The button and the dimming both come from <code>useFormStatus</code> and
        were passed nothing. The error, the preserved email and the counter come
        from <code>useActionState</code>. Submit a bad address: the message
        appears and your text survives. Submit a good one: React clears the
        uncontrolled field for you.
      </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 62 of 119 decoded in the React.js track. One more won't hurt.

Back to track