Skip to main content
React · lesson 19 of 22
React advanced Lesson 19 of 22

Actions and useActionState

Wire a <form action={fn}> to useActionState, watch isPending flip false-true-false across a real submission, return an error instead of throwing one, and learn why user-event needs a clear() after React resets the form.

By SkillByExample · Updated

react actions useActionState forms vitest

Every example so far that handled a form submission did it the imperative way: an onSubmit handler calling event.preventDefault(), reading fields by hand, calling setState itself. React 19 adds a second path — pass a function straight to a <form>’s action prop, and React calls it with the submitted FormData, tracks whether it’s still running, and resets the form once it finishes. useActionState is the hook that keeps that function’s last returned value around so the next call can build on it. This lesson wires both up for real, and finds one real gap between what React does to the form and what the testing library used to drive it expects.

Project Setup

The same kind of Vitest project as Retrying After an Error, minus react-error-boundary — nothing in this lesson throws:

{
  "name": "react-app",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "vitest": "5.0.5",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.8",
    "@testing-library/jest-dom": "7.0.1",
    "@vitejs/plugin-react": "6.1.2",
    "jsdom": "30.1.3"
  }
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.js"],
  },
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";

afterEach(() => {
  cleanup();
});

Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1, and grep strips Vitest’s own Start at/Duration lines, since their numbers change on every run:

$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund

added 98 packages in 32s

useActionState: Previous State, FormData, and isPending

signup is the action function. React calls it with two arguments: whatever it returned last time (or useActionState’s own initial value, the first time), and the submitted FormData:

// src/actions-and-useactionstate/SignupForm.tsx
import { useActionState } from "react";

const initialState = { error: null, savedEmail: null };

async function signup(previousState: typeof initialState, formData: FormData) {
  console.log("signup called, previousState:", JSON.stringify(previousState));
  const email = formData.get("email");
  console.log("submitted FormData email:", email);

  await new Promise((resolve) => setTimeout(resolve, 10));

  if (typeof email !== "string" || !email.includes("@")) {
    return { error: "Enter a valid email", savedEmail: null };
  }
  return { error: null, savedEmail: email };
}

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(signup, initialState);
  console.log("render, isPending:", isPending);

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Sign up"}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
      {state.savedEmail && <p>Saved: {state.savedEmail}</p>}
    </form>
  );
}

useActionState(signup, initialState) returns three things: state (what signup last returned, or initialState before any call), formAction (a wrapped version of signup to hand to <form action={...}>), and isPending (true from the moment formAction is called until its promise settles). The component itself logs on every render, so the test below can read isPending’s whole history straight out of the console, not just its final value:

// src/actions-and-useactionstate/SignupForm.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "./SignupForm.js";

describe("useActionState drives isPending and hands the action prior state plus FormData", () => {
  it("logs isPending false -> true -> false around one submission", async () => {
    const user = userEvent.setup();
    render(<SignupForm />);

    await user.type(screen.getByLabelText("Email"), "a@b.c");
    await user.click(screen.getByRole("button", { name: "Sign up" }));

    const saved = await screen.findByText("Saved: a@b.c");
    expect(saved).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/SignupForm.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/SignupForm.test.tsx > useActionState drives isPending and hands the action prior state plus FormData > logs isPending false -> true -> false around one submission
render, isPending: false

stdout | src/actions-and-useactionstate/SignupForm.test.tsx > useActionState drives isPending and hands the action prior state plus FormData > logs isPending false -> true -> false around one submission
signup called, previousState: {"error":null,"savedEmail":null}
submitted FormData email: a@b.c
render, isPending: true

stdout | src/actions-and-useactionstate/SignupForm.test.tsx > useActionState drives isPending and hands the action prior state plus FormData > logs isPending false -> true -> false around one submission
render, isPending: false

 ✓ src/actions-and-useactionstate/SignupForm.test.tsx > useActionState drives isPending and hands the action prior state plus FormData > logs isPending false -> true -> false around one submission 448ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Three renders, in order: isPending: false on mount, isPending: true the instant formAction is called (before signup’s own await even settles — React flips it synchronously as part of starting the action), and isPending: false again once the artificial delay passes and signup returns. signup called, previousState: {"error":null,"savedEmail":null} confirms the first argument is exactly useActionState’s own initialState, and submitted FormData email: a@b.c confirms formData.get("email") reads the real value the input held at submit time, not a snapshot React or the test kept separately.

Returning an Error Instead of Throwing One

signup already returns { error: "Enter a valid email", savedEmail: null } for an invalid address instead of throwing — the same function handles both outcomes, and SignupForm already renders state.error as a role="alert" paragraph:

// src/actions-and-useactionstate/InvalidEmail.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "./SignupForm.js";

describe("an invalid submission returns an error instead of throwing one", () => {
  it("renders the error as role=alert, with no thrown exception anywhere", async () => {
    const user = userEvent.setup();
    render(<SignupForm />);

    await user.type(screen.getByLabelText("Email"), "not-an-email");
    await user.click(screen.getByRole("button", { name: "Sign up" }));

    const alert = await screen.findByRole("alert");
    console.log("alert text:", alert.textContent);
    expect(screen.queryByText(/Saved:/)).not.toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/InvalidEmail.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/InvalidEmail.test.tsx > an invalid submission returns an error instead of throwing one > renders the error as role=alert, with no thrown exception anywhere
render, isPending: false

stdout | src/actions-and-useactionstate/InvalidEmail.test.tsx > an invalid submission returns an error instead of throwing one > renders the error as role=alert, with no thrown exception anywhere
signup called, previousState: {"error":null,"savedEmail":null}
submitted FormData email: not-an-email
render, isPending: true

stdout | src/actions-and-useactionstate/InvalidEmail.test.tsx > an invalid submission returns an error instead of throwing one > renders the error as role=alert, with no thrown exception anywhere
render, isPending: false

stdout | src/actions-and-useactionstate/InvalidEmail.test.tsx > an invalid submission returns an error instead of throwing one > renders the error as role=alert, with no thrown exception anywhere
alert text: Enter a valid email

 ✓ src/actions-and-useactionstate/InvalidEmail.test.tsx > an invalid submission returns an error instead of throwing one > renders the error as role=alert, with no thrown exception anywhere 459ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

alert text: Enter a valid email — no stack trace, no stderr, nothing for Vitest to report as an uncaught error, because nothing was thrown. isPending still runs its full false -> true -> false cycle (it’s logged by the same component, just not asserted on here); the only difference from the happy path is which branch of signup produced the state SignupForm rendered. That’s the whole benefit of returning the error: the failure case is just another value flowing through the same state, isPending, and JSX a reader already wrote for success.

The Form Auto-Resets — and user-event’s Cache Doesn’t Know

React resets an uncontrolled form’s fields once its action settles, the same as a native HTML form reset would. The email input is uncontrolled here — no value or onChange — so this reset happens entirely outside React’s own state:

// src/actions-and-useactionstate/FormReset.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "./SignupForm.js";

describe("React resets the form after the action settles", () => {
  it("the input's real DOM value is empty, but a second type() without clear() is wrong", async () => {
    const user = userEvent.setup();
    render(<SignupForm />);
    const input = screen.getByLabelText("Email") as HTMLInputElement;

    await user.type(input, "a@b.c");
    await user.click(screen.getByRole("button", { name: "Sign up" }));
    await screen.findByText("Saved: a@b.c");

    console.log("DOM value right after reset:", JSON.stringify(input.value));

    await user.type(input, "d@e.f");
    console.log("typed 'd@e.f' again, without clear():", JSON.stringify(input.value));
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/FormReset.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/FormReset.test.tsx > React resets the form after the action settles > the input's real DOM value is empty, but a second type() without clear() is wrong
render, isPending: false

stdout | src/actions-and-useactionstate/FormReset.test.tsx > React resets the form after the action settles > the input's real DOM value is empty, but a second type() without clear() is wrong
signup called, previousState: {"error":null,"savedEmail":null}
submitted FormData email: a@b.c
render, isPending: true

stdout | src/actions-and-useactionstate/FormReset.test.tsx > React resets the form after the action settles > the input's real DOM value is empty, but a second type() without clear() is wrong
render, isPending: false

stdout | src/actions-and-useactionstate/FormReset.test.tsx > React resets the form after the action settles > the input's real DOM value is empty, but a second type() without clear() is wrong
DOM value right after reset: ""

stdout | src/actions-and-useactionstate/FormReset.test.tsx > React resets the form after the action settles > the input's real DOM value is empty, but a second type() without clear() is wrong
typed 'd@e.f' again, without clear(): "a@b.cd@e.f"

 ✓ src/actions-and-useactionstate/FormReset.test.tsx > React resets the form after the action settles > the input's real DOM value is empty, but a second type() without clear() is wrong 461ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

DOM value right after reset: "" — React really did clear the field; nothing is left over from a@b.c. But the very next line shows the trap: typing d@e.f into that same, now-empty input produces "a@b.cd@e.f", not "d@e.f". user-event keeps its own model of the element’s value and cursor position so it can compute realistic key-by-key edits, and that model still remembers a@b.c — with the cursor sitting right after it — from before the reset; React’s DOM change never told it otherwise. The new characters get typed at that stale cursor position, appended after text that, as far as the real DOM is concerned, was never there.

The fix is await user.clear(input) before typing again, in its own test file — splitting it out avoids a real but unrelated flake Vitest’s own verbose reporter has: the ”✓ passed” marker for one test and the next test’s first console.log line can print in either order across runs, a race that has nothing to do with the form itself.

// src/actions-and-useactionstate/FormResetFixed.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "./SignupForm.js";

describe("user.clear() keeps user-event's cached value in sync with the reset form", () => {
  it("calling clear() first makes the second submission correct", async () => {
    const user = userEvent.setup();
    render(<SignupForm />);
    const input = screen.getByLabelText("Email") as HTMLInputElement;

    await user.type(input, "a@b.c");
    await user.click(screen.getByRole("button", { name: "Sign up" }));
    await screen.findByText("Saved: a@b.c");

    await user.clear(input);
    await user.type(input, "d@e.f");
    console.log("typed 'd@e.f' after clear():", JSON.stringify(input.value));

    await user.click(screen.getByRole("button", { name: "Sign up" }));
    const saved = await screen.findByText("Saved: d@e.f");
    expect(saved).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/FormResetFixed.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct
render, isPending: false

stdout | src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct
signup called, previousState: {"error":null,"savedEmail":null}
submitted FormData email: a@b.c
render, isPending: true

stdout | src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct
render, isPending: false

stdout | src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct
typed 'd@e.f' after clear(): "d@e.f"

stdout | src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct
signup called, previousState: {"error":null,"savedEmail":"a@b.c"}
submitted FormData email: d@e.f
render, isPending: true

stdout | src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct
render, isPending: false

 ✓ src/actions-and-useactionstate/FormResetFixed.test.tsx > user.clear() keeps user-event's cached value in sync with the reset form > calling clear() first makes the second submission correct 436ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

typed 'd@e.f' after clear(): "d@e.f", and the second submission saves exactly that. Any test that submits the same uncontrolled form more than once needs this — the reset is real, but only clear() tells user-event about it.

Practice

1. Read every submitted field generically with Object.fromEntries(formData.entries()) instead of calling .get() field by field, after adding a second plan field to the form.

Solution
// src/actions-and-useactionstate/practice/AllFieldsForm.tsx
import { useActionState } from "react";

const initialState = { fields: null as Record<string, FormDataEntryValue> | null };

async function signupWithPlan(_previousState: typeof initialState, formData: FormData) {
  const fields = Object.fromEntries(formData.entries());
  console.log("all fields:", JSON.stringify(fields));
  return { fields };
}

export function AllFieldsForm() {
  const [state, formAction] = useActionState(signupWithPlan, initialState);

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" />
      <label htmlFor="plan">Plan</label>
      <input id="plan" name="plan" defaultValue="free" />
      <button type="submit">Sign up</button>
      {state.fields && <p>Saved: {JSON.stringify(state.fields)}</p>}
    </form>
  );
}
// src/actions-and-useactionstate/practice/AllFieldsForm.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AllFieldsForm } from "./AllFieldsForm.js";

describe("reading every field generically with formData.entries()", () => {
  it("logs both fields from one FormData object", async () => {
    const user = userEvent.setup();
    render(<AllFieldsForm />);

    await user.type(screen.getByLabelText("Email"), "a@b.c");
    await user.click(screen.getByRole("button", { name: "Sign up" }));

    const saved = await screen.findByText(/Saved:/);
    expect(saved).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/practice/AllFieldsForm.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/practice/AllFieldsForm.test.tsx > reading every field generically with formData.entries() > logs both fields from one FormData object
all fields: {"email":"a@b.c","plan":"free"}

 ✓ src/actions-and-useactionstate/practice/AllFieldsForm.test.tsx > reading every field generically with formData.entries() > logs both fields from one FormData object 450ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

all fields: {"email":"a@b.c","plan":"free"} — formData.entries() reads both inputs in one pass, including plan, whose value came from defaultValue rather than anything the test typed. Object.fromEntries turns that iterator into a plain object with no per-field code to maintain as the form grows.

2. Assert that the submit button is actually disabled while isPending is true, instead of only reading it from a console log.

Solution

A short, fixed setTimeout is enough to separate renders in the earlier examples, but asserting on the DOM mid-flight needs the delay to stay open until the test itself says to close it — a fixed millisecond count can race against however fast the machine running it happens to be. SlowSignupForm uses a manually-released gate instead, so nothing resolves until releaseSignup() is actually called:

// src/actions-and-useactionstate/practice/SlowSignupForm.tsx
import { useActionState } from "react";

let releaseGate: (() => void) | null = null;

export function releaseSignup() {
  releaseGate?.();
  releaseGate = null;
}

const initialState = { savedEmail: null as string | null };

async function slowSignup(_previousState: typeof initialState, formData: FormData) {
  const email = formData.get("email") as string;
  await new Promise<void>((resolve) => {
    releaseGate = resolve;
  });
  return { savedEmail: email };
}

export function SlowSignupForm() {
  const [state, formAction, isPending] = useActionState(slowSignup, initialState);

  return (
    <form action={formAction}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving..." : "Sign up"}
      </button>
      {state.savedEmail && <p>Saved: {state.savedEmail}</p>}
    </form>
  );
}
// src/actions-and-useactionstate/practice/DisabledWhilePending.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SlowSignupForm, releaseSignup } from "./SlowSignupForm.js";

describe("the submit button reflects isPending directly in the DOM", () => {
  it("is disabled while pending, and re-enabled once the action resolves", async () => {
    const user = userEvent.setup();
    render(<SlowSignupForm />);

    await user.type(screen.getByLabelText("Email"), "a@b.c");
    const clickPromise = user.click(screen.getByRole("button", { name: "Sign up" }));

    const pendingButton = await screen.findByRole("button", { name: "Saving..." });
    console.log("disabled while pending:", pendingButton.hasAttribute("disabled"));

    releaseSignup();
    await clickPromise;
    const settledButton = await screen.findByRole("button", { name: "Sign up" });
    console.log("disabled once settled:", settledButton.hasAttribute("disabled"));
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/practice/DisabledWhilePending.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/practice/DisabledWhilePending.test.tsx > the submit button reflects isPending directly in the DOM > is disabled while pending, and re-enabled once the action resolves
disabled while pending: true

stdout | src/actions-and-useactionstate/practice/DisabledWhilePending.test.tsx > the submit button reflects isPending directly in the DOM > is disabled while pending, and re-enabled once the action resolves
disabled once settled: false

 ✓ src/actions-and-useactionstate/practice/DisabledWhilePending.test.tsx > the submit button reflects isPending directly in the DOM > is disabled while pending, and re-enabled once the action resolves 319ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

disabled while pending: true catches the button mid-flight, by name ("Saving...") rather than waiting for the final state — findByRole polls until that exact accessible name shows up, which only happens during the pending render. Nothing races to get there: slowSignup’s own promise doesn’t resolve until this test calls releaseSignup(), so the pending state is guaranteed to still be showing when the assertion runs, no matter how fast or slow the machine running it is. disabled once settled: false, read only after releaseSignup() and clickPromise both complete, confirms the disabled={isPending} prop really does flip both ways, not just at the end.

3. Make the action throw a real Error instead of returning { error }, and see what useActionState does with it — nothing wraps the call in a try/catch.

Solution
// src/actions-and-useactionstate/practice/ThrowingAction.tsx
import { useActionState } from "react";

async function alwaysThrows(_previousState: unknown, _formData: FormData) {
  throw new Error("signup service unreachable");
}

export function ThrowingActionForm() {
  const [, formAction, isPending] = useActionState(alwaysThrows, null);

  return (
    <form action={formAction}>
      <button type="submit" disabled={isPending}>
        Sign up
      </button>
    </form>
  );
}
// src/actions-and-useactionstate/practice/ThrowingAction.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThrowingActionForm } from "./ThrowingAction.js";

describe("a thrown error inside an action has no try/catch around the call", () => {
  it("surfaces as an unhandled error, with no error state to render", async () => {
    const user = userEvent.setup();
    render(<ThrowingActionForm />);

    await user.click(screen.getByRole("button", { name: "Sign up" }));
    console.error("looking for the button after the throw:", screen.getByRole("button").textContent);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 sh -c 'npx vitest run src/actions-and-useactionstate/practice/ThrowingAction.test.tsx --reporter=verbose; echo vitest exit code $?' 2>&1 | sed "s/\x1b\[[0-9;]*m//g" | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/actions-and-useactionstate/practice/ThrowingAction.test.tsx > a thrown error inside an action has no try/catch around the call > surfaces as an unhandled error, with no error state to render
An error occurred in the <ThrowingActionForm> component.

Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.


 × src/actions-and-useactionstate/practice/ThrowingAction.test.tsx > a thrown error inside an action has no try/catch around the call > surfaces as an unhandled error, with no error state to render 316ms
   → Unable to find an accessible element with the role "button"

There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole

Ignored nodes: comments, script, style
<body>
  <div />
</body>

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/actions-and-useactionstate/practice/ThrowingAction.test.tsx > a thrown error inside an action has no try/catch around the call > surfaces as an unhandled error, with no error state to render
TestingLibraryElementError: Unable to find an accessible element with the role "button"

There are no accessible roles. But there might be some inaccessible roles. If you wish to access them, then set the `hidden` option to `true`. Learn more about this here: https://testing-library.com/docs/dom-testing-library/api-queries#byrole

Ignored nodes: comments, script, style
<body>
  <div />
</body>
 ❯ Object.getElementError node_modules/@testing-library/dom/dist/config.js:37:19
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:76:38
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:52:17
 ❯ node_modules/@testing-library/dom/dist/query-helpers.js:95:19
 ❯ src/actions-and-useactionstate/practice/ThrowingAction.test.tsx:13:69
     11|
     12|     await user.click(screen.getByRole("button", { name: "Sign up" }));
     13|     console.error("looking for the button after the throw:", screen.ge…
       |                                                                     ^
     14|   });
     15| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯

⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

⎯⎯⎯⎯⎯ Uncaught Exception ⎯⎯⎯⎯⎯
Error: signup service unreachable
 ❯ alwaysThrows src/actions-and-useactionstate/practice/ThrowingAction.tsx:5:9
      3|
      4| async function alwaysThrows(_previousState: unknown, _formData: FormDa…
      5|   throw new Error("signup service unreachable");
       |         ^
      6| }
      7|
 ❯ runActionStateAction node_modules/react-dom/cjs/react-dom-client.development.js:8764:29
 ❯ dispatchActionState node_modules/react-dom/cjs/react-dom-client.development.js:8747:13
 ❯ node_modules/react-dom/cjs/react-dom-client.development.js:9342:22
 ❯ startTransition node_modules/react-dom/cjs/react-dom-client.development.js:9270:27
 ❯ startHostTransition node_modules/react-dom/cjs/react-dom-client.development.js:9333:7
 ❯ listener node_modules/react-dom/cjs/react-dom-client.development.js:20788:21
 ❯ executeDispatch node_modules/react-dom/cjs/react-dom-client.development.js:20804:9
 ❯ processDispatchQueue node_modules/react-dom/cjs/react-dom-client.development.js:20861:19
 ❯ node_modules/react-dom/cjs/react-dom-client.development.js:21423:9

This error originated in "src/actions-and-useactionstate/practice/ThrowingAction.test.tsx" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The last test to run before this error was "surfaces as an unhandled error, with no error state to render". This means either:
- the error was thrown while Vitest was running this test, or
- the error was thrown after the test completed, and this was the most recent test at that point.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
     Errors  1 error

vitest exit code 1

vitest exit code 1 — the test itself fails now, not just an unhandled-error report alongside it: screen.getByRole("button") can’t find anything, because the whole root unmounted. The stderr line explains why: An error occurred in the <ThrowingActionForm> component. Consider adding an error boundary to your tree... — React does route a thrown action error to the nearest error boundary, the same as it would route a thrown render error; there just isn’t one in this tree to catch it, so React falls back to its default for that case and unmounts everything it was managing. That’s the same shape Actions Outside a Form’s ThrowCancelsQueue example found for a thrown queued action, not Error Boundaries’s onClick case — an event-handler throw never reaches an error boundary at all, and that one leaves the DOM intact. Returning { error } the way signup does keeps the failure inside the normal render path, with the component still mounted; throwing takes the whole tree down with it.

4. Submit a third time, with its own clear() first, and confirm the clear-then-type pattern holds for every submission after the first one — not just a one-time fix.

Solution
// src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SignupForm } from "../SignupForm.js";

describe("clear() before type() holds for every repeat submission, not just the first fix", () => {
  it("a third submission after a second clear()+type() still saves the right value", async () => {
    const user = userEvent.setup();
    render(<SignupForm />);
    const input = screen.getByLabelText("Email") as HTMLInputElement;

    await user.type(input, "a@b.c");
    await user.click(screen.getByRole("button", { name: "Sign up" }));
    await screen.findByText("Saved: a@b.c");

    await user.clear(input);
    await user.type(input, "d@e.f");
    await user.click(screen.getByRole("button", { name: "Sign up" }));
    await screen.findByText("Saved: d@e.f");

    await user.clear(input);
    await user.type(input, "g@h.i");
    await user.click(screen.getByRole("button", { name: "Sign up" }));

    const saved = await screen.findByText("Saved: g@h.i");
    console.log("third submission saved:", saved.textContent);
    expect(saved).toBeInTheDocument();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-and-useactionstate-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
render, isPending: false

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
signup called, previousState: {"error":null,"savedEmail":null}
submitted FormData email: a@b.c
render, isPending: true

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
render, isPending: false

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
signup called, previousState: {"error":null,"savedEmail":"a@b.c"}
submitted FormData email: d@e.f
render, isPending: true

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
render, isPending: false

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
signup called, previousState: {"error":null,"savedEmail":"d@e.f"}
submitted FormData email: g@h.i
render, isPending: true

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
render, isPending: false

stdout | src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value
third submission saved: Saved: g@h.i

 ✓ src/actions-and-useactionstate/practice/ThirdSubmission.test.tsx > clear() before type() holds for every repeat submission, not just the first fix > a third submission after a second clear()+type() still saves the right value 588ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Each reset needs its own clear() before the next type(), every time — not just the first time the bug shows up. React resets the form after every action it drives, including this third one, so skipping clear() on any later submission would reproduce the exact same stale-cache problem the second one fixed.

Frequently Asked Questions

What does passing a function to a form's action prop actually do?
React calls that function with the form's submitted FormData instead of letting the browser navigate or reload the page — the default behavior a plain HTML form submission would otherwise trigger. React also tracks the call as a transition: while the function's returned promise is still pending, every useActionState hook wired to it reports isPending as true, and once it resolves, React both updates that hook's state and resets the form's own uncontrolled fields, the same way a native form reset would.
Why does useActionState take the action function and return a wrapped one?
useActionState(action, initialState) adds the piece a plain action function can't do on its own: remembering the state an action returned last time, so the next call can receive it as its own first argument. It hands back a three-item array — the latest state, a wrapped action to pass to action (or call directly), and isPending — all three kept in sync with every call that wrapped action makes.
Should a form action throw an error or return one?
Return one, as part of the state object, unless the error is truly unexpected. A thrown error inside an action has no try/catch wrapped around the call for you, but React doesn't just drop it either — it routes the throw to the nearest error boundary, the same as it would route a thrown render error. With no boundary in the tree, React's default takes over instead: it unmounts the whole tree it was managing. Returning { error: '...' } keeps the failure in the same state value isPending and the rest of the form already read, rendered with the same JSX a reader already wrote for the success case.
Why does typing into a form field after a submission produce the wrong value?
React resets an uncontrolled form's fields to empty right after its action settles, the same as a native form reset — the DOM input's real value becomes an empty string. @testing-library/user-event keeps its own cached model of what it last typed into that element, though, and that cache isn't told about React's reset. Typing again without first calling user.clear() on the input makes user-event insert new keystrokes against its stale cached value instead of the field's real, now-empty one, producing a value that was never actually typed by anyone.
Is this the same thing as a Next.js Server Action?
No — a Server Action is this same action-function-on-a-form mechanism, but the function itself runs on the server, is called over the network from the client, and needs the 'use server' directive. This lesson's actions run entirely in the browser (or, here, in jsdom under a test), with no network or server involved; see Server Actions and Server Components in the Next.js track for the server half.