React · lesson 21 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
5useOptimistic and useFormStatus
Show the new value from useOptimistic before a save actually finishes, watch it roll back to the real value when the save rejects, and read a form's pending state in a child SubmitButton with useFormStatus instead of passing a prop down to it.
By SkillByExample · Updated
Actions Outside a Form showed that calling a dispatch function doesn’t block the UI while it runs — the previous render stays on screen until the action settles. useOptimistic goes one step further: instead of leaving the previous value on screen, it shows the new value immediately, before anything has confirmed it actually happened. useFormStatus solves an adjacent problem — reading whether a form’s action is still pending from inside a child component, with no prop passed down to get there. This lesson wires a like button that shows the optimistic like count the instant it’s clicked, watches it roll back when the save rejects, and reads that same pending status from a SubmitButton that never receives it as a prop.
Project Setup
The same kind of Vitest project as Actions Outside a Form — 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-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund
added 98 packages in 24s
useOptimistic: Show It Now, Confirm — or Roll Back — Later
useOptimistic(likes, updateFn) returns two things: shownLikes, the value to render, and addOptimisticLike, a function that computes a new shownLikes from whatever likes currently is — without touching likes itself. likeAction calls it the instant it runs, before saveLike even starts:
// src/useoptimistic-and-useformstatus/LikeButton.tsx
import { useState, useOptimistic } from "react";
import { SubmitButton } from "./SubmitButton.js";
async function saveLike(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
throw new Error("network down");
}
export function LikeButton() {
const [likes, setLikes] = useState(0);
const [shownLikes, addOptimisticLike] = useOptimistic(
likes,
(state, amount: number) => state + amount
);
console.log(`render likes=${likes} shown=${shownLikes}`);
async function likeAction() {
addOptimisticLike(1);
try {
await saveLike();
setLikes((current) => current + 1);
} catch (error) {
console.log("save failed:", (error as Error).message);
}
}
return (
<form action={likeAction}>
<p>Likes: {shownLikes}</p>
<SubmitButton />
</form>
);
}
saveLike always rejects with network down, on purpose — this example is about what happens when the save doesn’t go through. SubmitButton, covered in full in the next section, just needs to exist here for likeAction to have something to submit:
// src/useoptimistic-and-useformstatus/SubmitButton.tsx
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
console.log("SubmitButton pending =", pending);
return (
<button type="submit" disabled={pending}>
Save
</button>
);
}
The test clicks the button once, waits long enough for the rejected save to be handled, and reads the final rendered text:
// src/useoptimistic-and-useformstatus/LikeButton.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LikeButton } from "./LikeButton.js";
describe("useOptimistic shows the new value immediately, then rolls back if the save fails", () => {
it("logs the optimistic render, the pending state, the failure, and the rollback", async () => {
const user = userEvent.setup();
render(<LikeButton />);
await user.click(screen.getByRole("button", { name: "Save" }));
await new Promise((resolve) => setTimeout(resolve, 120));
const final = await screen.findByText("Likes: 0");
console.log("final:", final.textContent);
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/useoptimistic-and-useformstatus/LikeButton.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/useoptimistic-and-useformstatus/LikeButton.test.tsx > useOptimistic shows the new value immediately, then rolls back if the save fails > logs the optimistic render, the pending state, the failure, and the rollback
render likes=0 shown=0
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/LikeButton.test.tsx > useOptimistic shows the new value immediately, then rolls back if the save fails > logs the optimistic render, the pending state, the failure, and the rollback
render likes=0 shown=1
SubmitButton pending = true
stdout | src/useoptimistic-and-useformstatus/LikeButton.test.tsx > useOptimistic shows the new value immediately, then rolls back if the save fails > logs the optimistic render, the pending state, the failure, and the rollback
save failed: network down
stdout | src/useoptimistic-and-useformstatus/LikeButton.test.tsx > useOptimistic shows the new value immediately, then rolls back if the save fails > logs the optimistic render, the pending state, the failure, and the rollback
render likes=0 shown=0
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/LikeButton.test.tsx > useOptimistic shows the new value immediately, then rolls back if the save fails > logs the optimistic render, the pending state, the failure, and the rollback
final: Likes: 0
✓ src/useoptimistic-and-useformstatus/LikeButton.test.tsx > useOptimistic shows the new value immediately, then rolls back if the save fails > logs the optimistic render, the pending state, the failure, and the rollback 393ms
Test Files 1 passed (1)
Tests 1 passed (1)
The very first render logs render likes=0 shown=0, then SubmitButton pending = false — the mount, with nothing pending yet. Clicking Save produces a second render with shown=1 while likes is still 0: addOptimisticLike(1) already ran, computed 0 + 1 from the real state, and that’s what’s on screen before saveLike has even started its artificial delay. SubmitButton pending = true logs in that same render — React flips the form’s pending status the instant the action starts, in the same commit as the optimistic update. Then save failed: network down logs from the catch block once saveLike rejects. Because that catch block never calls setLikes, the real state is still 0 — and a final render brings shown back down to match it: render likes=0 shown=0, SubmitButton pending = false. final: Likes: 0 confirms what’s actually on screen: the optimistic 1 a reader briefly saw is gone, with no rollback code anywhere in LikeButton. There was never a stored “previous optimistic value” to restore — shownLikes is always just updateFn applied to whatever likes currently is, and likes never changed.
useFormStatus: Reading pending From the Nearest <form>
SubmitButton takes no props. It can’t — useFormStatus() reads the pending status of whichever <form> happens to be its nearest ancestor at render time, which only works from inside that form’s own subtree. TwoForms renders the exact same SubmitButton twice, once per form, to show that each call reads only its own form’s status. saveA waits on a manually-released gate instead of a fixed delay, so the test can read “is it pending” at an exact, repeatable point instead of racing a timer:
// src/useoptimistic-and-useformstatus/TwoForms.tsx
import { useState } from "react";
import { SubmitButton } from "./SubmitButton.js";
let releaseGate: (() => void) | null = null;
export function releaseFormA() {
releaseGate?.();
releaseGate = null;
}
export function TwoForms() {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
async function saveA() {
await new Promise<void>((resolve) => {
releaseGate = resolve;
});
setCountA((current) => current + 1);
}
async function saveB() {
await new Promise((resolve) => setTimeout(resolve, 30));
setCountB((current) => current + 1);
}
return (
<div>
<form action={saveA}>
<p>Form A: {countA}</p>
<SubmitButton />
</form>
<form action={saveB}>
<p>Form B: {countB}</p>
<SubmitButton />
</form>
</div>
);
}
The test submits form A only, reads both buttons’ disabled attribute straight from the DOM while A’s save is still gated open, then releases it:
// src/useoptimistic-and-useformstatus/TwoForms.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TwoForms, releaseFormA } from "./TwoForms.js";
describe("useFormStatus reads only the status of its own nearest form", () => {
it("submitting form A leaves form B's SubmitButton unaffected", async () => {
const user = userEvent.setup();
render(<TwoForms />);
const [buttonA, buttonB] = screen.getAllByRole("button", { name: "Save" });
await user.click(buttonA);
console.log("A disabled while pending:", buttonA.hasAttribute("disabled"));
console.log("B disabled while A is pending:", buttonB.hasAttribute("disabled"));
releaseFormA();
const settled = await screen.findByText("Form A: 1");
expect(settled).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/useoptimistic-and-useformstatus/TwoForms.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/useoptimistic-and-useformstatus/TwoForms.test.tsx > useFormStatus reads only the status of its own nearest form > submitting form A leaves form B's SubmitButton unaffected
SubmitButton pending = false
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/TwoForms.test.tsx > useFormStatus reads only the status of its own nearest form > submitting form A leaves form B's SubmitButton unaffected
SubmitButton pending = true
stdout | src/useoptimistic-and-useformstatus/TwoForms.test.tsx > useFormStatus reads only the status of its own nearest form > submitting form A leaves form B's SubmitButton unaffected
A disabled while pending: true
B disabled while A is pending: false
stdout | src/useoptimistic-and-useformstatus/TwoForms.test.tsx > useFormStatus reads only the status of its own nearest form > submitting form A leaves form B's SubmitButton unaffected
SubmitButton pending = true
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/TwoForms.test.tsx > useFormStatus reads only the status of its own nearest form > submitting form A leaves form B's SubmitButton unaffected
SubmitButton pending = false
✓ src/useoptimistic-and-useformstatus/TwoForms.test.tsx > useFormStatus reads only the status of its own nearest form > submitting form A leaves form B's SubmitButton unaffected 170ms
Test Files 1 passed (1)
Tests 1 passed (1)
A disabled while pending: true and B disabled while A is pending: false — clicking form A’s button flips only form A’s SubmitButton into its pending state; form B’s copy of the exact same component, with the exact same zero props, stays unaffected because useFormStatus inside it is reading form B’s own status, not some value shared across the page. There’s no prop either button could have received to tell it which form it belongs to — the nearest-<form> lookup is how it knows.
Practice
1. Make saveLike resolve instead of reject, and confirm the optimistic value never has to roll back because the real state catches up to match it first.
Solution
// src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.tsx
import { useState, useOptimistic } from "react";
import { SubmitButton } from "../SubmitButton.js";
async function saveLike(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
}
export function LikeButtonSucceeds() {
const [likes, setLikes] = useState(0);
const [shownLikes, addOptimisticLike] = useOptimistic(
likes,
(state, amount: number) => state + amount
);
console.log(`render likes=${likes} shown=${shownLikes}`);
async function likeAction() {
addOptimisticLike(1);
await saveLike();
setLikes((current) => current + 1);
}
return (
<form action={likeAction}>
<p>Likes: {shownLikes}</p>
<SubmitButton />
</form>
);
}
// src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LikeButtonSucceeds } from "./LikeButtonSucceeds.js";
describe("a successful save never needs to roll the optimistic value back", () => {
it("the real state catches up to match the optimistic one", async () => {
const user = userEvent.setup();
render(<LikeButtonSucceeds />);
await user.click(screen.getByRole("button", { name: "Save" }));
await new Promise((resolve) => setTimeout(resolve, 120));
const final = await screen.findByText("Likes: 1");
console.log("final:", final.textContent);
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx > a successful save never needs to roll the optimistic value back > the real state catches up to match the optimistic one
render likes=0 shown=0
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx > a successful save never needs to roll the optimistic value back > the real state catches up to match the optimistic one
render likes=0 shown=1
SubmitButton pending = true
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx > a successful save never needs to roll the optimistic value back > the real state catches up to match the optimistic one
render likes=1 shown=2
SubmitButton pending = true
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx > a successful save never needs to roll the optimistic value back > the real state catches up to match the optimistic one
render likes=1 shown=1
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx > a successful save never needs to roll the optimistic value back > the real state catches up to match the optimistic one
final: Likes: 1
✓ src/useoptimistic-and-useformstatus/practice/LikeButtonSucceeds.test.tsx > a successful save never needs to roll the optimistic value back > the real state catches up to match the optimistic one 303ms
Test Files 1 passed (1)
Tests 1 passed (1)
The same render likes=0 shown=1 optimistic render happens first, same as the failing version. But right after it comes a render this lesson’s main example never produces: render likes=1 shown=2, still pending = true. setLikes already ran — likes is 1 — yet shownLikes is 2, not 1. The action hasn’t returned yet when that render happens, so React still considers the optimistic update “in flight” and keeps reapplying it on top of whatever the real state currently is, even though the real state already moved. Only once likeAction itself finishes does React drop the queued update and render one more time from the real state alone: render likes=1 shown=1, SubmitButton pending = false — which is what final: Likes: 1 reads. A successful save doesn’t just avoid a visible rollback; it briefly overshoots past the value it settles on, because confirming the real state and clearing the optimistic one aren’t the same step.
2. Add a second, independent LikeButton next to the first on the same page and confirm clicking one doesn’t optimistically update the other.
Solution
A fixed delay on the first button’s save would race the test’s own read of “while pending” against the save actually rejecting — a timing-sensitive assertion that can flip either way depending on how long the delay actually takes to elapse, the same class of race Actions and useActionState found between a timer and a system under unpredictable load. FirstLikeButton uses a manually-released gate instead, the same fix that lesson used, so the test controls exactly when the save fails:
// src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.tsx
import { useState, useOptimistic } from "react";
import { SubmitButton } from "../SubmitButton.js";
import { LikeButton } from "../LikeButton.js";
let releaseGate: (() => void) | null = null;
export function releaseFirstSave() {
releaseGate?.();
releaseGate = null;
}
async function gatedSaveLike(): Promise<void> {
await new Promise<void>((resolve) => {
releaseGate = resolve;
});
throw new Error("network down");
}
function FirstLikeButton() {
const [likes, setLikes] = useState(0);
const [shownLikes, addOptimisticLike] = useOptimistic(
likes,
(state, amount: number) => state + amount
);
async function likeAction() {
addOptimisticLike(1);
try {
await gatedSaveLike();
setLikes((current) => current + 1);
} catch (error) {
console.log("save failed:", (error as Error).message);
}
}
return (
<form action={likeAction}>
<p>Likes: {shownLikes}</p>
<SubmitButton />
</form>
);
}
export function TwoLikeButtons() {
return (
<div>
<FirstLikeButton />
<LikeButton />
</div>
);
}
// src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx
import { describe, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TwoLikeButtons, releaseFirstSave } from "./TwoLikeButtons.js";
describe("two independent LikeButton instances don't share optimistic state", () => {
it("clicking the first one's Save button leaves the second's count at 0", async () => {
const user = userEvent.setup();
render(<TwoLikeButtons />);
const [firstSave] = screen.getAllByRole("button", { name: "Save" });
await user.click(firstSave);
const [firstLikes, secondLikes] = screen.getAllByText(/Likes: /);
console.log("first while pending:", firstLikes.textContent);
console.log("second while first is pending:", secondLikes.textContent);
releaseFirstSave();
await new Promise((resolve) => setTimeout(resolve, 50));
const [firstAfterRollback, secondAfterRollback] = screen.getAllByText(/Likes: /);
console.log("first after rollback:", firstAfterRollback.textContent);
console.log("second after rollback:", secondAfterRollback.textContent);
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0
SubmitButton pending = false
render likes=0 shown=0
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0
SubmitButton pending = true
stdout | src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0
first while pending: Likes: 1
second while first is pending: Likes: 0
stdout | src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0
save failed: network down
stdout | src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0
SubmitButton pending = false
stdout | src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0
first after rollback: Likes: 0
second after rollback: Likes: 0
✓ src/useoptimistic-and-useformstatus/practice/TwoLikeButtons.test.tsx > two independent LikeButton instances don't share optimistic state > clicking the first one's Save button leaves the second's count at 0 233ms
Test Files 1 passed (1)
Tests 1 passed (1)
first while pending: Likes: 1 and second while first is pending: Likes: 0 — each LikeButton call creates its own likes state and its own useOptimistic pair, so there’s no value for a click on one instance to reach into on the other. Releasing the gate lets gatedSaveLike reject, exactly like the main example’s saveLike — save failed: network down logs, and first after rollback: Likes: 0 confirms the first instance rolled back on its own. second after rollback: Likes: 0 never had anywhere to roll back from: it was never clicked, so its likes and shownLikes stayed at their initial value the entire time.
3. Read the submitted FormData inside SubmitButton itself, using useFormStatus()’s own data field, instead of passing anything down from the parent.
Solution
// src/useoptimistic-and-useformstatus/practice/SubmitButtonWithData.tsx
import { useFormStatus } from "react-dom";
export function SubmitButtonWithData() {
const { pending, data } = useFormStatus();
const note = pending && data ? data.get("note") : null;
console.log("pending:", pending, "note:", note);
return (
<button type="submit" disabled={pending}>
Save
</button>
);
}
// src/useoptimistic-and-useformstatus/practice/NoteForm.tsx
import { useState } from "react";
import { SubmitButtonWithData } from "./SubmitButtonWithData.js";
export function NoteForm() {
const [saved, setSaved] = useState<string | null>(null);
async function saveNote(formData: FormData) {
await new Promise((resolve) => setTimeout(resolve, 20));
setSaved(formData.get("note") as string);
}
return (
<form action={saveNote}>
<input name="note" defaultValue="buy milk" />
<SubmitButtonWithData />
{saved && <p>Saved: {saved}</p>}
</form>
);
}
// src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { NoteForm } from "./NoteForm.js";
describe("useFormStatus exposes the pending submission's own FormData", () => {
it("SubmitButtonWithData reads the note field with no prop passed to it", async () => {
const user = userEvent.setup();
render(<NoteForm />);
await user.click(screen.getByRole("button", { name: "Save" }));
const saved = await screen.findByText("Saved: buy milk");
await new Promise((resolve) => setTimeout(resolve, 120));
expect(saved).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx > useFormStatus exposes the pending submission's own FormData > SubmitButtonWithData reads the note field with no prop passed to it
pending: false note: null
stdout | src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx > useFormStatus exposes the pending submission's own FormData > SubmitButtonWithData reads the note field with no prop passed to it
pending: true note: buy milk
stdout | src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx > useFormStatus exposes the pending submission's own FormData > SubmitButtonWithData reads the note field with no prop passed to it
pending: true note: buy milk
stdout | src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx > useFormStatus exposes the pending submission's own FormData > SubmitButtonWithData reads the note field with no prop passed to it
pending: false note: null
✓ src/useoptimistic-and-useformstatus/practice/NoteForm.test.tsx > useFormStatus exposes the pending submission's own FormData > SubmitButtonWithData reads the note field with no prop passed to it 385ms
Test Files 1 passed (1)
Tests 1 passed (1)
pending: true note: buy milk logs while the submission is in flight — data is useFormStatus()’s own copy of the exact FormData the form submitted, available to any component inside it, read the same way pending is. Once the action settles, data goes back to null along with pending, which is why the component guards the read with pending && data rather than reading data.get(...) unconditionally.
4. Pass a prop-based pending from LikeButton down to a non-useFormStatus version of the submit button instead, but — on purpose — never wire up a real value for it. Confirm the button stays stuck at whatever was hardcoded.
Solution
// src/useoptimistic-and-useformstatus/practice/PropDrilledButton.tsx
export function PropDrilledButton({ pending }: { pending: boolean }) {
return (
<button type="submit" disabled={pending}>
Save
</button>
);
}
// src/useoptimistic-and-useformstatus/practice/LikeButtonWithPropDrilling.tsx
import { useState, useOptimistic } from "react";
import { PropDrilledButton } from "./PropDrilledButton.js";
async function saveLike(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
}
export function LikeButtonWithPropDrilling() {
const [likes, setLikes] = useState(0);
const [shownLikes, addOptimisticLike] = useOptimistic(
likes,
(state, amount: number) => state + amount
);
async function likeAction() {
addOptimisticLike(1);
await saveLike();
setLikes((current) => current + 1);
}
return (
<form action={likeAction}>
<p>Likes: {shownLikes}</p>
<PropDrilledButton pending={false} />
</form>
);
}
// src/useoptimistic-and-useformstatus/practice/LikeButtonWithPropDrilling.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LikeButtonWithPropDrilling } from "./LikeButtonWithPropDrilling.js";
describe("a hardcoded pending prop never reflects the real submission status", () => {
it("the button is never disabled, even while the action is running", async () => {
const user = userEvent.setup();
render(<LikeButtonWithPropDrilling />);
const button = screen.getByRole("button", { name: "Save" });
await user.click(button);
console.log("disabled right after click:", button.hasAttribute("disabled"));
const final = await screen.findByText("Likes: 1");
await new Promise((resolve) => setTimeout(resolve, 120));
console.log("disabled once settled:", button.hasAttribute("disabled"));
expect(final).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-useoptimistic-and-useformstatus-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/useoptimistic-and-useformstatus/practice/LikeButtonWithPropDrilling.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonWithPropDrilling.test.tsx > a hardcoded pending prop never reflects the real submission status > the button is never disabled, even while the action is running
disabled right after click: false
stdout | src/useoptimistic-and-useformstatus/practice/LikeButtonWithPropDrilling.test.tsx > a hardcoded pending prop never reflects the real submission status > the button is never disabled, even while the action is running
disabled once settled: false
✓ src/useoptimistic-and-useformstatus/practice/LikeButtonWithPropDrilling.test.tsx > a hardcoded pending prop never reflects the real submission status > the button is never disabled, even while the action is running 286ms
Test Files 1 passed (1)
Tests 1 passed (1)
disabled right after click: false and disabled once settled: false — PropDrilledButton renders exactly whatever pending value its parent hardcoded, false, forever, because LikeButtonWithPropDrilling never bothered wiring up a real one. That alone doesn’t show a prop can’t work — it shows what happens when nobody tracks and forwards one: LikeButton would still need its own pending state, set and cleared at the right moments, before a prop could carry anything real down to the button. useFormStatus in the original SubmitButton skips that wiring entirely, because it reads the form’s real status directly — the SubmitButton pending = true lines earlier in this lesson are what an actually-updating value looks like, which this hardcoded false never becomes.