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

useOptimistic 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

react useOptimistic useFormStatus actions vitest

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.

Frequently Asked Questions

Is the value useOptimistic returns a separate piece of state from the real one?
No. useOptimistic(state, updateFn) takes the real state as its first argument and returns a second, derived value that starts out equal to it; calling the function it returns applies updateFn against the real state and shows that result immediately, without waiting for anything to confirm it. The moment the real state itself changes — including not changing, if a pending action never commits an update — the optimistic value recalculates from that new real state and the temporary one disappears. There's nothing to roll back by hand; the rollback is just the optimistic value reverting to whatever the real state already was.
What happens if the save succeeds instead of failing?
The optimistic value never has to revert to an older one, but it can still change more than once before settling: if the action updates the real state itself before it returns, React keeps reapplying the still-pending optimistic update on top of that new real value until the action's own promise actually resolves, which can briefly overshoot past the number it settles on. Only once the action finishes does the optimistic update get dropped and the display matches the real state exactly, with nothing further changing it.
Does useOptimistic only work inside a form action?
No — it works inside any transition. A form's action prop is the most common caller, because React already wraps that call in a transition for you, but calling the function useOptimistic returns from inside a plain startTransition(() => ...) works exactly the same way, the same relationship Actions Outside a Form found between useActionState's dispatch function and forms.
Why does useFormStatus have to be called from a component rendered inside the form, not the component that renders the <form> itself?
useFormStatus reads the status of the nearest enclosing <form>, and a component can't be its own descendant. Calling it in the same component that renders <form action={...}> always returns the default, non-pending status, because that call happens outside any form's subtree. Move the call into a child rendered between the form's opening and closing tags, and it reads the real status of the form wrapping it.
Is useFormStatus just prop-drilling in disguise, with an extra function call instead of a prop?
No — the prop a parent would otherwise pass down doesn't exist at all. SubmitButton in this lesson takes zero props; it works unmodified inside any <form>, because useFormStatus reads the pending state of whichever form happens to be its nearest ancestor at render time, not a value its own parent chose to forward.