React · lesson 19 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Actions 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
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.