React · lesson 20 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 Outside a Form: startTransition and the Queue
Call an action outside any <form> by wrapping it in startTransition, watch two back-to-back clicks queue instead of racing, see the real warning for skipping startTransition, and confirm whether a thrown error wipes out a queued action waiting behind it.
By SkillByExample · Updated
The previous lesson wired an action to <form action={formAction}>, and React handled the plumbing: calling the function with FormData, tracking isPending, resetting the form once it settled. None of that plumbing is actually tied to forms — useActionState hands back a plain function, callable from anywhere, as long as the caller wraps it in a transition itself. This lesson calls that function directly from a button’s onClick, proves the queuing is real by holding one call open with a gate and dispatching a second one while the first is still provably in flight, reproduces the real warning for forgetting the transition wrapper, and checks whether a thrown error inside one queued action takes out everything still waiting behind it.
Project Setup
The same Vitest project as Actions and useActionState — nothing here needs react-error-boundary or tsx either:
{
"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:
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund
added 98 packages in 25s
Two Clicks, One Queue
A fixed delay like setTimeout(resolve, 10) can’t actually prove a second click gets queued behind a first one still running — by the time the test’s second await user.click(...) returns, several real milliseconds have already passed, often more than the delay itself, so the first call may have already finished before the second one is even dispatched. That would just be two calls running one after another, not a queue. QueueCounter uses a gate instead: increment doesn’t resolve on its own at all, it waits for the test to call releaseGate for that specific label, so the test can dispatch B while A is still provably, verifiably pending, then release A and see exactly when B’s turn starts:
// src/actions-outside-a-form/QueueOrder.tsx
import { useActionState, startTransition } from "react";
const gates: Record<string, (() => void) | null> = {};
export function releaseGate(label: string) {
gates[label]?.();
gates[label] = null;
}
async function increment(previousState: number, label: string) {
console.log(`start ${label}, prev=${previousState}`);
await new Promise<void>((resolve) => {
gates[label] = resolve;
});
const next = previousState + 1;
console.log(`end ${label}, returns ${next}`);
return next;
}
export function QueueCounter() {
const [count, dispatch, isPending] = useActionState(increment, 0);
console.log("render count:", count, "isPending:", isPending);
function handleClick(label: string) {
startTransition(() => {
dispatch(label);
});
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => handleClick("A")}>A</button>
<button onClick={() => handleClick("B")}>B</button>
</div>
);
}
The test clicks A then B back-to-back, with A’s gate still closed when B is clicked, then releases A’s gate and, after giving React a moment to run with that, releases B’s:
// src/actions-outside-a-form/QueueOrder.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueueCounter, releaseGate } from "./QueueOrder.js";
describe("two quick clicks queue instead of racing", () => {
it("B starts only after A finishes, with A's result as B's previous state", async () => {
const user = userEvent.setup();
render(<QueueCounter />);
await user.click(screen.getByRole("button", { name: "A" }));
await user.click(screen.getByRole("button", { name: "B" }));
console.log("--- both clicked, A's gate still closed ---");
releaseGate("A");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing B's gate ---");
releaseGate("B");
const final = await screen.findByText("Count: 2");
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/QueueOrder.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
render count: 0 isPending: false
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
start A, prev=0
render count: 0 isPending: true
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
render count: 0 isPending: true
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
--- both clicked, A's gate still closed ---
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
end A, returns 1
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
start B, prev=1
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
--- releasing B's gate ---
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
end B, returns 2
stdout | src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state
render count: 2 isPending: false
✓ src/actions-outside-a-form/QueueOrder.test.tsx > two quick clicks queue instead of racing > B starts only after A finishes, with A's result as B's previous state 267ms
Test Files 1 passed (1)
Tests 1 passed (1)
start B never logs while A’s gate is still closed — clicking B doesn’t start a second, concurrent call to increment, it just enqueues B behind A. isPending stays true across the whole handoff: there’s no isPending: false render between end A and start B, because React never considers the transition finished while another dispatch is still waiting in the queue. Only once releaseGate("A") lets end A, returns 1 happen does start B, prev=1 log, immediately, still inside the same pending transition. The two calls run one at a time, in the order they were dispatched, each one receiving the previous call’s return value — the same guarantee useActionState already gave two form submissions in the previous lesson, except there each submission was awaited to completion before the next began, so nothing in that lesson actually had two calls queued at once the way these two clicks do.
Forgetting startTransition
Calling dispatch directly, with no startTransition anywhere in the call stack, still calls the action — it just does it unsafely:
// src/actions-outside-a-form/DirectDispatch.tsx
import { useActionState } from "react";
async function increment(previousState: number) {
return previousState + 1;
}
export function DirectDispatchCounter() {
const [count, dispatch] = useActionState(increment, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch()}>C</button>
</div>
);
}
// src/actions-outside-a-form/DirectDispatch.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DirectDispatchCounter } from "./DirectDispatch.js";
describe("calling dispatch with no startTransition anywhere", () => {
it("still runs the action, but React warns about it", async () => {
const user = userEvent.setup();
render(<DirectDispatchCounter />);
await user.click(screen.getByRole("button", { name: "C" }));
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/DirectDispatch.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/actions-outside-a-form/DirectDispatch.test.tsx > calling dispatch with no startTransition anywhere > still runs the action, but React warns about it
An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an `action` or `formAction` prop.
stderr | src/actions-outside-a-form/DirectDispatch.test.tsx > calling dispatch with no startTransition anywhere > still runs the action, but React warns about it
A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:
await act(() => ...)
✓ src/actions-outside-a-form/DirectDispatch.test.tsx > calling dispatch with no startTransition anywhere > still runs the action, but React warns about it 172ms
Test Files 1 passed (1)
Tests 1 passed (1)
The first warning is the real one: An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an actionorformAction prop. — React’s own words for exactly what this example does. The second warning is specific to this test harness, not a real browser, and its own wording points at the cause: “A component suspended inside an act scope.” Without an enclosing transition, the component suspends on the action’s own pending promise instead of React tracking it as a transition — and with no transition for Testing Library’s act() to attach to, it has nothing it knows to wait for, so the test moves on while the action is still running underneath it. A real page wouldn’t print that one; it would just have an isPending that never reliably reflects whether the action is still running — which is the whole problem the first warning is naming.
Does a Throw Cancel the Rest of the Queue?
Two actions queue behind each other, as the first example showed. If the first one throws instead of returning, does the second one still get its turn? ThrowCancelsQueueCounter uses a manually-released gate on each action instead of a fixed delay, so the test can release the second action’s gate explicitly, after the first one has already thrown, and check the log for start after — the line that would appear only if the second action’s body ever actually ran:
// src/actions-outside-a-form/ThrowCancelsQueue.tsx
import { useActionState, startTransition } from "react";
const gates: Record<string, (() => void) | null> = {};
export function releaseGate(label: string) {
gates[label]?.();
gates[label] = null;
}
async function riskyIncrement(previousState: number, label: string) {
console.log(`start ${label}, prev=${previousState}`);
await new Promise<void>((resolve) => {
gates[label] = resolve;
});
if (label === "throws") {
throw new Error(`boom from ${label}`);
}
const next = previousState + 1;
console.log(`end ${label}, returns ${next}`);
return next;
}
export function ThrowCancelsQueueCounter() {
const [count, dispatch] = useActionState(riskyIncrement, 0);
function handleClick(label: string) {
startTransition(() => {
dispatch(label);
});
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => handleClick("throws")}>Throws</button>
<button onClick={() => handleClick("after")}>After</button>
</div>
);
}
// src/actions-outside-a-form/ThrowCancelsQueue.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThrowCancelsQueueCounter, releaseGate } from "./ThrowCancelsQueue.js";
describe("a thrown error inside a queued action", () => {
it("the second queued action never starts, even after its own gate is released", async () => {
const user = userEvent.setup();
render(<ThrowCancelsQueueCounter />);
await user.click(screen.getByRole("button", { name: "Throws" }));
await user.click(screen.getByRole("button", { name: "After" }));
console.log("--- releasing throws ---");
releaseGate("throws");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing after ---");
releaseGate("after");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- final body:", document.body.innerHTML, "---");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/ThrowCancelsQueue.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/ThrowCancelsQueue.test.tsx > a thrown error inside a queued action > the second queued action never starts, even after its own gate is released
start throws, prev=0
stdout | src/actions-outside-a-form/ThrowCancelsQueue.test.tsx > a thrown error inside a queued action > the second queued action never starts, even after its own gate is released
--- releasing throws ---
stderr | src/actions-outside-a-form/ThrowCancelsQueue.test.tsx > a thrown error inside a queued action > the second queued action never starts, even after its own gate is released
An error occurred in the <ThrowCancelsQueueCounter> 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.
stdout | src/actions-outside-a-form/ThrowCancelsQueue.test.tsx > a thrown error inside a queued action > the second queued action never starts, even after its own gate is released
--- releasing after ---
stdout | src/actions-outside-a-form/ThrowCancelsQueue.test.tsx > a thrown error inside a queued action > the second queued action never starts, even after its own gate is released
--- final body: <div></div> ---
✓ src/actions-outside-a-form/ThrowCancelsQueue.test.tsx > a thrown error inside a queued action > the second queued action never starts, even after its own gate is released 346ms
⎯⎯⎯⎯⎯⎯ 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: boom from throws
❯ riskyIncrement src/actions-outside-a-form/ThrowCancelsQueue.tsx:17:11
15| });
16| if (label === "throws") {
17| throw new Error(`boom from ${label}`);
| ^
18| }
19| const next = previousState + 1;
This error originated in "src/actions-outside-a-form/ThrowCancelsQueue.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 "the second queued action never starts, even after its own gate is released". 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 passed (1)
Tests 1 passed (1)
Errors 1 error
start throws, prev=0 logs, but start after never does — not before releasing throws’s gate, not after releasing after’s own gate too. document.body.innerHTML ends up <div></div>: testing-library’s own container, now empty. This is the same default behavior Error Boundaries already found for any uncaught render error with nothing catching it — React unmounts the whole root it was managing, not just the one component that threw, there or here. The difference is what that costs here specifically: useActionState’s queue of still-waiting calls lives inside the Fiber of the component instance that threw, and that instance is part of the root that just got unmounted. Unmounting the tree doesn’t just remove what was on screen, it throws away the queue too, along with anything still waiting in it. There’s no special “cancel the queue” step — the queue simply doesn’t exist anymore once the thing that owned it doesn’t either.
Return, Don’t Throw
The fix is the same one the previous lesson used for a form action: have the action return an error value instead of throwing one. Nothing unmounts, so nothing queued behind it is lost — proved the same way as the first example, with a gate held closed on the failing action while After is dispatched behind it:
// src/actions-outside-a-form/ReturnNotThrow.tsx
import { useActionState, startTransition } from "react";
type State = { value: number; error: string | null };
const initialState: State = { value: 0, error: null };
const gates: Record<string, (() => void) | null> = {};
export function releaseGate(label: string) {
gates[label]?.();
gates[label] = null;
}
async function safeIncrement(previousState: State, label: string): Promise<State> {
console.log(`start ${label}, prev=${JSON.stringify(previousState)}`);
await new Promise<void>((resolve) => {
gates[label] = resolve;
});
if (label === "errors") {
const result = { value: previousState.value, error: `boom from ${label}` };
console.log(`end ${label}, returns ${JSON.stringify(result)}`);
return result;
}
const result = { value: previousState.value + 1, error: null };
console.log(`end ${label}, returns ${JSON.stringify(result)}`);
return result;
}
export function ReturnNotThrowCounter() {
const [state, dispatch] = useActionState(safeIncrement, initialState);
function handleClick(label: string) {
startTransition(() => {
dispatch(label);
});
}
return (
<div>
<p>Count: {state.value}</p>
{state.error && <p role="alert">{state.error}</p>}
<button onClick={() => handleClick("errors")}>Errors</button>
<button onClick={() => handleClick("after")}>After</button>
</div>
);
}
// src/actions-outside-a-form/ReturnNotThrow.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReturnNotThrowCounter, releaseGate } from "./ReturnNotThrow.js";
describe("returning an error instead of throwing keeps the queue alive", () => {
it("the action queued behind the failing one still runs", async () => {
const user = userEvent.setup();
render(<ReturnNotThrowCounter />);
await user.click(screen.getByRole("button", { name: "Errors" }));
await user.click(screen.getByRole("button", { name: "After" }));
console.log("--- both clicked, errors's gate still closed ---");
releaseGate("errors");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing after's gate ---");
releaseGate("after");
const final = await screen.findByText("Count: 1");
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/ReturnNotThrow.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs
start errors, prev={"value":0,"error":null}
stdout | src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs
--- both clicked, errors's gate still closed ---
stdout | src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs
end errors, returns {"value":0,"error":"boom from errors"}
stdout | src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs
start after, prev={"value":0,"error":"boom from errors"}
stdout | src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs
--- releasing after's gate ---
stdout | src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs
end after, returns {"value":1,"error":null}
✓ src/actions-outside-a-form/ReturnNotThrow.test.tsx > returning an error instead of throwing keeps the queue alive > the action queued behind the failing one still runs 336ms
Test Files 1 passed (1)
Tests 1 passed (1)
start after never logs while errors’s gate is still closed, the same proof of real queuing as the first example. Only once releaseGate("errors") lets end errors, returns {"value":0,"error":"boom from errors"} happen does start after log right behind it, reading that same error value as its own previousState and still landing on Count: 1. Nothing unmounted, so nothing waiting in the queue was lost — the failure is just a value ReturnNotThrowCounter renders as a role="alert" paragraph, the same shape Actions and useActionState used for a form.
Practice
1. Queue three clicks instead of two (A, B, C) and confirm the ordering guarantee holds for more than a pair of actions.
Solution
// src/actions-outside-a-form/practice/ThreeActionQueue.tsx
import { useActionState, startTransition } from "react";
const gates: Record<string, (() => void) | null> = {};
export function releaseGate(label: string) {
gates[label]?.();
gates[label] = null;
}
async function increment(previousState: number, label: string) {
console.log(`start ${label}, prev=${previousState}`);
await new Promise<void>((resolve) => {
gates[label] = resolve;
});
const next = previousState + 1;
console.log(`end ${label}, returns ${next}`);
return next;
}
export function ThreeActionQueueCounter() {
const [count, dispatch] = useActionState(increment, 0);
function handleClick(label: string) {
startTransition(() => {
dispatch(label);
});
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => handleClick("A")}>A</button>
<button onClick={() => handleClick("B")}>B</button>
<button onClick={() => handleClick("C")}>C</button>
</div>
);
}
// src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThreeActionQueueCounter, releaseGate } from "./ThreeActionQueue.js";
describe("a three-item queue still runs in order", () => {
it("A, B, and C each see the previous one's result", async () => {
const user = userEvent.setup();
render(<ThreeActionQueueCounter />);
await user.click(screen.getByRole("button", { name: "A" }));
await user.click(screen.getByRole("button", { name: "B" }));
await user.click(screen.getByRole("button", { name: "C" }));
console.log("--- all three clicked, A's gate still closed ---");
releaseGate("A");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing B's gate ---");
releaseGate("B");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing C's gate ---");
releaseGate("C");
const final = await screen.findByText("Count: 3");
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
start A, prev=0
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
--- all three clicked, A's gate still closed ---
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
end A, returns 1
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
start B, prev=1
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
--- releasing B's gate ---
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
end B, returns 2
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
start C, prev=2
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
--- releasing C's gate ---
stdout | src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result
end C, returns 3
✓ src/actions-outside-a-form/practice/ThreeActionQueue.test.tsx > a three-item queue still runs in order > A, B, and C each see the previous one's result 326ms
Test Files 1 passed (1)
Tests 1 passed (1)
Neither start B nor start C logs until A’s own gate is released — all three clicks land while A is still provably pending, and the queue still holds B and C in order behind it. start B reads prev=1, start C reads prev=2 — the queue keeps chaining each call’s return value into the next one’s argument no matter how many calls are already waiting, the same guarantee the two-click example showed for a queue of exactly two.
2. Fix the forgotten-startTransition example from this lesson by wrapping the dispatch() call, and confirm both warnings disappear.
Solution
// src/actions-outside-a-form/practice/DispatchWrappedInTransition.tsx
import { useActionState, startTransition } from "react";
async function increment(previousState: number) {
return previousState + 1;
}
export function DispatchWrappedInTransitionCounter() {
const [count, dispatch, isPending] = useActionState(increment, 0);
console.log("render count:", count, "isPending:", isPending);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => startTransition(() => dispatch())}>C</button>
</div>
);
}
// src/actions-outside-a-form/practice/DispatchWrappedInTransition.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DispatchWrappedInTransitionCounter } from "./DispatchWrappedInTransition.js";
describe("wrapping dispatch in startTransition fixes both warnings", () => {
it("isPending cycles normally and no warning prints", async () => {
const user = userEvent.setup();
render(<DispatchWrappedInTransitionCounter />);
await user.click(screen.getByRole("button", { name: "C" }));
const final = await screen.findByText("Count: 1");
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/practice/DispatchWrappedInTransition.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/practice/DispatchWrappedInTransition.test.tsx > wrapping dispatch in startTransition fixes both warnings > isPending cycles normally and no warning prints
render count: 0 isPending: false
stdout | src/actions-outside-a-form/practice/DispatchWrappedInTransition.test.tsx > wrapping dispatch in startTransition fixes both warnings > isPending cycles normally and no warning prints
render count: 0 isPending: true
stdout | src/actions-outside-a-form/practice/DispatchWrappedInTransition.test.tsx > wrapping dispatch in startTransition fixes both warnings > isPending cycles normally and no warning prints
render count: 1 isPending: false
✓ src/actions-outside-a-form/practice/DispatchWrappedInTransition.test.tsx > wrapping dispatch in startTransition fixes both warnings > isPending cycles normally and no warning prints 268ms
Test Files 1 passed (1)
Tests 1 passed (1)
render count: 0 isPending: true shows up between the mount render and the final one — isPending now tracks the call correctly, and neither of the two warnings from the earlier example prints. The only change was adding startTransition around the exact same dispatch() call.
3. Queue three actions where the middle one throws (first, throws, third) and confirm the one before it still completes normally while the one after it never starts.
Solution
// src/actions-outside-a-form/practice/ThrowInTheMiddle.tsx
import { useActionState, startTransition } from "react";
const gates: Record<string, (() => void) | null> = {};
export function releaseGate(label: string) {
gates[label]?.();
gates[label] = null;
}
async function riskyIncrement(previousState: number, label: string) {
console.log(`start ${label}, prev=${previousState}`);
await new Promise<void>((resolve) => {
gates[label] = resolve;
});
if (label === "throws") {
throw new Error(`boom from ${label}`);
}
const next = previousState + 1;
console.log(`end ${label}, returns ${next}`);
return next;
}
export function ThrowInTheMiddleCounter() {
const [count, dispatch] = useActionState(riskyIncrement, 0);
function handleClick(label: string) {
startTransition(() => {
dispatch(label);
});
}
return (
<div>
<p>Count: {count}</p>
<button onClick={() => handleClick("first")}>First</button>
<button onClick={() => handleClick("throws")}>Throws</button>
<button onClick={() => handleClick("third")}>Third</button>
</div>
);
}
// src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ThrowInTheMiddleCounter, releaseGate } from "./ThrowInTheMiddle.js";
describe("a throw in the middle of a three-item queue", () => {
it("the one before it finishes, the one after it never starts", async () => {
const user = userEvent.setup();
render(<ThrowInTheMiddleCounter />);
await user.click(screen.getByRole("button", { name: "First" }));
await user.click(screen.getByRole("button", { name: "Throws" }));
await user.click(screen.getByRole("button", { name: "Third" }));
console.log("--- all three clicked, first's gate still closed ---");
releaseGate("first");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing throws's gate ---");
releaseGate("throws");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing third's gate ---");
releaseGate("third");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- final body:", document.body.innerHTML, "---");
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
start first, prev=0
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
--- all three clicked, first's gate still closed ---
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
end first, returns 1
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
start throws, prev=1
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
--- releasing throws's gate ---
stderr | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
An error occurred in the <ThrowInTheMiddleCounter> 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.
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
--- releasing third's gate ---
stdout | src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts
--- final body: <div></div> ---
✓ src/actions-outside-a-form/practice/ThrowInTheMiddle.test.tsx > a throw in the middle of a three-item queue > the one before it finishes, the one after it never starts 512ms
⎯⎯⎯⎯⎯⎯ 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: boom from throws
❯ riskyIncrement src/actions-outside-a-form/practice/ThrowInTheMiddle.tsx:17:11
15| });
16| if (label === "throws") {
17| throw new Error(`boom from ${label}`);
| ^
18| }
19| const next = previousState + 1;
This error originated in "src/actions-outside-a-form/practice/ThrowInTheMiddle.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 "the one before it finishes, the one after it never starts". 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 passed (1)
Tests 1 passed (1)
Errors 1 error
All three clicks land before any gate is released — start throws doesn’t log until first’s own gate is released, and start third never logs at all, not even after throws’s gate (and then third’s own gate) are both released. end first, returns 1 logs once first’s gate opens, before throws ever gets its turn — the action ahead of the throw in the queue is unaffected by it. The final body is <div></div>: the position of the throw in the queue doesn’t matter, only that nothing after it gets a turn once the root it all lived in is gone.
4. Repeat the middle-of-three queue, but have the middle action return an error value instead of throwing, and confirm every action in the queue now completes.
Solution
// src/actions-outside-a-form/practice/ReturnInTheMiddle.tsx
import { useActionState, startTransition } from "react";
type State = { value: number; error: string | null };
const initialState: State = { value: 0, error: null };
const gates: Record<string, (() => void) | null> = {};
export function releaseGate(label: string) {
gates[label]?.();
gates[label] = null;
}
async function safeIncrement(previousState: State, label: string): Promise<State> {
console.log(`start ${label}, prev=${JSON.stringify(previousState)}`);
await new Promise<void>((resolve) => {
gates[label] = resolve;
});
if (label === "errors") {
const result = { value: previousState.value, error: `boom from ${label}` };
console.log(`end ${label}, returns ${JSON.stringify(result)}`);
return result;
}
const result = { value: previousState.value + 1, error: null };
console.log(`end ${label}, returns ${JSON.stringify(result)}`);
return result;
}
export function ReturnInTheMiddleCounter() {
const [state, dispatch] = useActionState(safeIncrement, initialState);
function handleClick(label: string) {
startTransition(() => {
dispatch(label);
});
}
return (
<div>
<p>Count: {state.value}</p>
{state.error && <p role="alert">{state.error}</p>}
<button onClick={() => handleClick("first")}>First</button>
<button onClick={() => handleClick("errors")}>Errors</button>
<button onClick={() => handleClick("third")}>Third</button>
</div>
);
}
// src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReturnInTheMiddleCounter, releaseGate } from "./ReturnInTheMiddle.js";
describe("an error return in the middle of a three-item queue", () => {
it("first and third both complete around it", async () => {
const user = userEvent.setup();
render(<ReturnInTheMiddleCounter />);
await user.click(screen.getByRole("button", { name: "First" }));
await user.click(screen.getByRole("button", { name: "Errors" }));
await user.click(screen.getByRole("button", { name: "Third" }));
console.log("--- all three clicked, first's gate still closed ---");
releaseGate("first");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing errors's gate ---");
releaseGate("errors");
await new Promise((resolve) => setTimeout(resolve, 50));
console.log("--- releasing third's gate ---");
releaseGate("third");
const final = await screen.findByText("Count: 2");
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-actions-outside-a-form-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
start first, prev={"value":0,"error":null}
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
--- all three clicked, first's gate still closed ---
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
end first, returns {"value":1,"error":null}
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
start errors, prev={"value":1,"error":null}
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
--- releasing errors's gate ---
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
end errors, returns {"value":1,"error":"boom from errors"}
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
start third, prev={"value":1,"error":"boom from errors"}
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
--- releasing third's gate ---
stdout | src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it
end third, returns {"value":2,"error":null}
✓ src/actions-outside-a-form/practice/ReturnInTheMiddle.test.tsx > an error return in the middle of a three-item queue > first and third both complete around it 332ms
Test Files 1 passed (1)
Tests 1 passed (1)
All three clicks land while first is still gated, and errors and third both wait their turn exactly as the earlier queue examples showed. first and third both run to completion, landing on Count: 2 — one increment from each of them, with errors contributing only its error message in between. Nothing in the queue after the error is lost, because returning it instead of throwing it never gives React a reason to unmount anything.