Skip to the solution
MediumFrontend6 min read

useFormStatus & useActionState — modern form handling in React 19

360 views

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.

  • forms
  • react-19
  • hooks

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 complete form: returned errors, preserved input, and a shared submit button
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>
  );
}

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 62 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 nextMedium

Async Server Components & waterfall prevention — fetching without blocking

Continue