Pending and optimistic UI
Show that a Server Action is running with useTransition, useActionState or useFormStatus, show its result at once with useOptimistic, and roll back if it fails.
- 01Show that an action is runningPick between useTransition, useActionState and useFormStatus for a button's pending state.
- 02Show the result before the server answersRun a Server Action in a transition and show its result at once with useOptimistic, then the server's answer.
- 03Handle a no from the serverTell what the visitor sees when an action returns a problem, and when it throws, and choose when not to be optimistic.
A cart that counts before the server answers
A Server Action is a request to the server, and a request takes time. On a fast connection, the visitor hardly notices. On a weak phone signal, they click Add a box and nothing changes. Did it work? Should they click again? Two things help them:
- A pending state: the page shows that the action is running, like a button that says
Adding…. - An optimistic update: the page shows the result the action will most likely have, before the server answers. Then it shows the server's real answer, and goes back if the server says no.
Here is the tea shop's Jasmine tea page, with both. It's a real Next.js 16.4.3 project, built with next build and run with next start. Real Chrome then made the network slow (every request waited before it went out, like on a weak signal) and clicked Add a box. The Steps tab opens on the page right after that click.
app/teas/jasmine/add-box.tsx"use client"; import { useOptimistic, useState, useTransition } from "react";import { addBox } from "./actions"; export default function AddBox({ boxes }: { boxes: number }) { const [shownBoxes, setShownBoxes] = useOptimistic(boxes); const [isPending, startTransition] = useTransition(); const [message, setMessage] = useState(""); function add() { startTransition(async () => { setShownBoxes(shownBoxes + 1); const result = await addBox(); startTransition(() => setMessage(result.message)); }); } return ( <div> <p>In your cart: {shownBoxes} {shownBoxes === 1 ? "box" : "boxes"}</p> <button onClick={add} disabled={isPending}> {isPending ? "Adding…" : "Add a box"} </button> <p aria-live="polite">{message}</p> </div> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
$5 a box. Only 2 boxes are left.
In your cart: 1 box
Total: $0
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas/jasmine200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22jasmine%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C82%5D
- next-action
- 00c43e74badee1ae18c4ec60327ad92863d76deb72
After step 3, the request was still on its way, and the page already said In your cart: 1 box. The button said Adding…, and the total still said Total: $0. Pick step 4: the server has answered, the button says Add a box again, and the total is $5.
The count and the total come from different places. The total, on line 10 of app/teas/jasmine/page.tsx, is rendered on the server, from the cart cookie. The count, on line 21 of add-box.tsx, is shownBoxes: a value the browser shows while the action runs. So the count changed at the click, and the total only when the server answered.
Adding…. The total comes from the server, so it changes only when the server answers. Shaded cards are values the browser showed before the server said so.At a busy café, the barista writes your name on the board the moment you order, with "making it" next to it. You don't wonder whether they heard you. When your cup is ready, your name comes off the board. If they've run out, they wipe your name and tell you.
- In real life: You order, and your name goes up on the board right away
- In Next.js: The count shows the new box at once, with
useOptimistic - In real life: "Making it" next to your name
- In Next.js: The button says
Adding…while the action runs - In real life: Your cup arrives, and your name comes off the board
- In Next.js: The server answers, and the page shows its real data
- In real life: They're out of jasmine: your name is wiped, and someone tells you
- In Next.js: The server says no: the count goes back, and a message says why
While a Server Action runs, show that it's running, and, when it will most likely work, show its result at once. React's useOptimistic holds the value to show while the action runs; when the action ends, the page shows the server's value again, which is the new one if it worked, and the old one if it didn't.
Three ways to know an action is running
A pending state needs one true or false: is the action still running? React has three hooks that give it, each for a different place in the code. Here is the cart button without any optimistic count, written three ways. Each was recorded on the same slow network:
app/teas/jasmine/add-box.tsxNext.jsapp/teas/jasmine/add-box.tsx"use client"; import { useTransition } from "react";import { addBox } from "./actions"; export default function AddBox({ boxes }: { boxes: number }) { const [isPending, startTransition] = useTransition(); function add() { startTransition(async () => { await addBox(); }); } return ( <div> <p>In your cart: {boxes} {boxes === 1 ? "box" : "boxes"}</p> <button onClick={add} disabled={isPending}> {isPending ? "Adding…" : "Add a box"} </button> </div> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
$5 a box. Only 2 boxes are left.
In your cart: 0 boxes
Total: $0
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas/jasmine200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22jasmine%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C82%5D
- next-action
- 00c2d44fa2aaf2cfccc803579c8791637e1a59a6c5
A plain button with onClick. Line 10 runs the action inside startTransition, and isPending from line 7 is true until it's done. While the request was on its way, the button said Adding….
All three showed Adding… after the click, and In your cart: 0 boxes, because nothing in them changes the count before the server answers. Step 4, in each, is the page after the answer: 1 box and $5. Each row says where the hook goes, and what it gives:
| Hook | Where you call it | What it gives you |
|---|---|---|
useTransition | In the component with the button. You start the action yourself, in startTransition. | isPending, true until the action and the updates it starts are done. |
useActionState | In the component that calls the action: usually the one that renders the form, or one that calls it inside startTransition. | Its third value, isPending, true while that action runs. |
useFormStatus | In a component rendered inside the form, like its button. | pending, true while the form around it is being sent, whatever its action. |
How to pick one:
- A button that isn't in a form, with its own
onClick:useTransitionis the simplest, as the optimistic cart above shows.useActionStateworks there too, if you call its action insidestartTransition; onlyuseFormStatusneeds a form around it. - A form whose answer you show:
useActionState. You already have it for the answer, so itsisPendingcosts nothing more. - A button component you reuse in many forms:
useFormStatus. It needs no props, because it finds the form it's in (Forms in Next.js showed why it must be inside the form).
Form actions and Transitions, in the React course, teach these hooks on their own.
useOptimistic: a value to show while the action runs
A pending state says "wait". An optimistic update says "done", a little early. Here is the cart's button with both:
"use client"; import { useOptimistic, useState, useTransition } from "react";import { addBox } from "./actions"; export default function AddBox({ boxes }: { boxes: number }) { const [shownBoxes, setShownBoxes] = useOptimistic(boxes); const [isPending, startTransition] = useTransition(); const [message, setMessage] = useState(""); function add() { startTransition(async () => { setShownBoxes(shownBoxes + 1); const result = await addBox(); startTransition(() => setMessage(result.message)); }); } return ( <div> <p>In your cart: {shownBoxes} {shownBoxes === 1 ? "box" : "boxes"}</p> <button onClick={add} disabled={isPending}> {isPending ? "Adding…" : "Add a box"} </button> <p aria-live="polite">{message}</p> </div> );} Line 1 makes it a Client Component, because useOptimistic, useTransition and useState keep state in the browser, which only Client Components can. On line 6, AddBox gets boxes, the number of boxes in the cart, from the server: line 9 of page.tsx reads it from the cookie and passes it in.
Line 7 is the new hook. useOptimistic(boxes) gives back two things:
shownBoxes: the value to show. When no action is running, it'sboxes, exactly.setShownBoxes: sets another value, for as long as the current action runs.
Line 13 calls setShownBoxes(shownBoxes + 1) inside an action, before the request leaves. React draws the new count at once. Line 21 shows shownBoxes, never boxes. When the action ends, shownBoxes is boxes again: the server's number, whatever it is by then. There's nothing to undo by hand. (Optimistic updates, in the React course, teaches the hook with plain functions.)
Compare the page with and without the optimistic count, right after the click:
app/teas/jasmine/add-box.tsxNext.jsapp/teas/jasmine/add-box.tsx"use client"; import { useTransition } from "react";import { addBox } from "./actions"; export default function AddBox({ boxes }: { boxes: number }) { const [isPending, startTransition] = useTransition(); function add() { startTransition(async () => { await addBox(); }); } return ( <div> <p>In your cart: {boxes} {boxes === 1 ? "box" : "boxes"}</p> <button onClick={add} disabled={isPending}> {isPending ? "Adding…" : "Add a box"} </button> </div> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
$5 a box. Only 2 boxes are left.
In your cart: 0 boxes
Total: $0
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/teas/jasmine200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22teas%22%2C%7B%22children%22%3A%5B%22jasmine%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C82%5D
- next-action
- 00c2d44fa2aaf2cfccc803579c8791637e1a59a6c5
Line 17 shows boxes, the server's count. While the request was on its way, the page said In your cart: 0 boxes and Adding…: the visitor knows something is happening, but not that it worked.
boxes is what the server sent; shownBoxes is what the page shows. While the action runs, they differ. When the transition ends, shownBoxes is boxes again: here, the server's new 1.Each row is one moment in the cart's recording, and the two values then:
| Moment | boxes, from the server | shownBoxes, on the page | The total |
|---|---|---|---|
| Before the first click | 0 | 0 | $0 |
| While the first box is on its way | 0 | 1 | $0 |
| After the server's answer | 1 | 1 | $5 |
| While the third box is on its way | 2 | 3 | $10 |
| After the server says no | 2 | 2 | $10 |
Where did the server's new boxes come from? Line 13 of actions.ts sets the cart cookie. When an action changes a cookie, Next.js renders the page again on the server, and sends it back in the same response as the action's answer. So boxes arrived as 1 at the same moment the action ended. Updating the UI after a mutation covers every way to send fresh data after an action.
Actions need a transition
An optimistic value lasts "while the action runs". React has to know when that is. An action, in React, is a function that runs inside a transition: an update React treats as one piece of work that may take a while, with a start and an end. The optimistic value lives from the start to the end.
Line 8 of add-box.tsx calls useTransition. It gives isPending and startTransition. On line 12, startTransition runs an async function: from that moment until the function and the updates it started are done, isPending is true, and line 22 turns the button off. Inside it:
- Line 13 sets the optimistic count, inside the transition.
- Line 14 calls the Server Action and awaits its answer.
- Line 15 stores the action's message. It's wrapped in
startTransitionagain, because React doesn't count a state update after anawaitas part of the transition. React's docs call this a current limit, and the Next.js docs wrap such updates the same way, so they show together with the rest of the transition's result.
Why not call setShownBoxes in a plain click handler? React's docs say the setter must be called inside an action. Outside one, React prints a warning in development, and the optimistic value only shows for a moment, since there's no transition for it to last through.
Some places are transitions already, so the setter works there without startTransition:
- A function given to a form's
action, or to a button'sformAction, like the wishlist's form. - The action given to
useActionState.
When the server says no
Optimism can be wrong. The shop has only 2 boxes of Jasmine tea, and the action checks it on the server, where the stock is:
"use server"; import { cookies } from "next/headers"; const boxesLeft = 2; export async function addBox() { const cookieStore = await cookies(); const boxes = Number(cookieStore.get("boxes")?.value ?? 0); if (boxes >= boxesLeft) { return { message: `Sorry, only ${boxesLeft} boxes are left.` }; } cookieStore.set("boxes", String(boxes + 1)); return { message: "" };} Line 9 reads the boxes already in the cart cookie, or 0. Line 10 compares them with boxesLeft, from line 5. With 2 boxes in the cart, line 11 returns a message, and the cookie doesn't change. Otherwise, line 13 stores one more box, and line 14 returns an empty message.
The recording went on: step 6 added a second box, on a normal network, and step 8 clicked a third time, slowly. Open the cart's Steps tab above and pick step 8: In your cart: 3 boxes and Adding…, because the browser couldn't know. Step 9 is the server's no: In your cart: 2 boxes, and Sorry, only 2 boxes are left. under the button. Nobody wrote code to undo the 3. The transition ended, boxes was still 2, so shownBoxes was 2. This is called rolling back.
Step through both trips, the yes and the no. Before you start, guess: in which step does the no reach the page?
The server says yesafter step 1Jasmine tea
$5 a box. Only 2 boxes are left.
In your cart: 0 boxes
Add a box
Total: $0
React is running: the page responds to clicks.
The page isn't complete yet.
The cart is empty. The click runs add, on line 11 of add-box.tsx.
Nothing on the screen changes while the request travels and the server works: the visitor sees the optimistic count the whole time. In both trips, the page changes again only in the last step, when the transition ends.
Returning the problem, or throwing it
The action could also throw when the shop has run out. Then there's no message to show: an error thrown inside the transition goes to the nearest error.tsx, as the Next.js docs say. Compare the two versions, after the third click:
app/teas/jasmine/actions.tsNext.jsapp/teas/jasmine/actions.ts"use server"; import { cookies } from "next/headers"; const boxesLeft = 2; export async function addBox() { const cookieStore = await cookies(); const boxes = Number(cookieStore.get("boxes")?.value ?? 0); if (boxes >= boxesLeft) { return { message: `Sorry, only ${boxesLeft} boxes are left.` }; } cookieStore.set("boxes", String(boxes + 1)); return { message: "" };}Each step was taken in real Chrome against next start. Pick one to see the page after it.
$5 a box. Only 2 boxes are left.
In your cart: 2 boxes
Sorry, only 2 boxes are left.
Total: $10
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
No requests during this step: the page already had what it needed
Line 11 returns the problem. The count went from 3 back to 2, and the message showed under the button. The rest of the page stayed.
When it returns the message, the page stays, with the count rolled back and the reason under the button. When it throws, the request answered 500, and app/teas/jasmine/error.tsx took the page's place. Chrome's console shows React's error #441: in production, the error's own message stays on the server. A sold-out tea is an expected problem, so return it, as Error handling recommends; keep throwing for what shouldn't happen at all.
A wishlist that shows a new tea at once
An optimistic list is common too: a new comment, a new note, a tea added to a wishlist. The new item shows at once, marked as saving, until the server has it. Here the visitor types Jasmine tea and clicks Add to wishlist, on a slow network:
app/wishlist/wishlist.tsx"use client"; import { useOptimistic } from "react";import { addToWishlist } from "./actions"; type Tea = { name: string; saving: boolean }; export default function Wishlist({ names }: { names: string[] }) { const saved = names.map((name) => ({ name, saving: false })); const [teas, addTea] = useOptimistic(saved, (current: Tea[], name: string) => [...current, { name, saving: true }]); async function addAction(formData: FormData) { const name = String(formData.get("tea") ?? "").trim(); addTea(name); await addToWishlist(name); } return ( <> <ul> {teas.map((tea) => ( <li key={tea.name}>{tea.saving ? `${tea.name} (saving…)` : tea.name}</li> ))} </ul> <form action={addAction}> <label htmlFor="tea">Tea</label> <input id="tea" name="tea" /> <button>Add to wishlist</button> </form> </> );}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Saved teas: 0
- Jasmine tea (saving…)
- Focus
- the button “Add to wishlist”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/wishlist200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22wishlist%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C98%5D%7D%2Cnull%2Cnull%2C82%5D
- next-action
- 409e155d589a4da70001ce9438fb951259da64883f
After step 4, the list showed Jasmine tea (saving…), while the server still said Saved teas: 0. After step 5, the server's answer: Jasmine tea without the mark, and Saved teas: 1. The box was emptied too, because React resets a form after its action finishes.
app/wishlist/wishlist.tsx uses useOptimistic with a second argument, a reducer: a function that makes the optimistic value from the current one and what you pass to the setter. On line 10, it takes the list and a tea's name, and returns the list with a new tea marked saving: true. Line 14 calls addTea(name). The reducer helps when the server's list changes while an action runs: React runs it again on the new list, so the new tea stays on top of it. Line 22 shows the mark.
addAction calls addTea without startTransition: it's given to the form's action on line 25, and React runs form actions in a transition. addAction itself runs in the browser, and calls the Server Action addToWishlist on line 15.
Not every action should be optimistic. If showing the result early could make the visitor do the wrong thing, show a pending state and wait. Sort these actions:
- Give a tea a star.
- Add a tea to your wishlist.
- Add a box of tea to the cart.
- Pay for an order.
- Book the last delivery slot of the day.
- Delete your account.
Sort each action: is it safe to show its result before the server answers?
Common mix-ups
- "useOptimistic saves the value." No. It only shows a value while an action runs. The Server Action saves, and the page shows the server's value when the action ends.
- "You have to undo the optimistic value when the action fails." No. When the transition ends, the hook returns the value passed in again. If the server changed nothing, that's the old value.
- "The setter works in any click handler." No. Call it inside an action: in
startTransition, a form action, or the action ofuseActionState. - "A pending state and an optimistic update are the same." No. One says the action is running; the other shows its result early. The cart shows both.
- "A failed action should throw." No. A problem the shop expects, like a sold-out tea, is a value to return and show. A thrown error replaces the page with
error.tsx. - "Every action should be optimistic." No. Payments, bookings others may take, and changes that can't be undone should wait for the server.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page.
The cart is empty. The visitor clicks Add a box on a slow network. While the request is on its way, what does the total line say?
"use client";
import { useOptimistic, useState, useTransition } from "react";
import { addBox } from "./actions";
export default function AddBox({ boxes }: { boxes: number }) {
const [shownBoxes, setShownBoxes] = useOptimistic(boxes);
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState("");
function add() {
startTransition(async () => {
setShownBoxes(shownBoxes + 1);
const result = await addBox();
startTransition(() => setMessage(result.message));
});
}
return (
<div>
<p>In your cart: {shownBoxes} {shownBoxes === 1 ? "box" : "boxes"}</p>
<button onClick={add} disabled={isPending}>
{isPending ? "Adding…" : "Add a box"}
</button>
<p aria-live="polite">{message}</p>
</div>
);
}
Total: $0. Only shownBoxes changes at the click. The total comes from the server, which hasn't answered yet. The recorded page, right after the click, said In your cart: 1 box, Adding… and Total: $0.
The shop's staff page has a Restock button, not in any form. Its onClick calls a Server Action. Which hook gives the button a pending state?
useTransition. The button has its own onClick, so you start the action yourself with startTransition, and isPending is true until it's done. useFormStatus needs a form around it. useActionState would work too, by calling its action inside startTransition, but for a button without a form useTransition is the simplest.
The cart holds 2 boxes, and the visitor clicks Add a box again. Once the server has answered, how many boxes does the count show?
"use server";
import { cookies } from "next/headers";
const boxesLeft = 2;
export async function addBox() {
const cookieStore = await cookies();
const boxes = Number(cookieStore.get("boxes")?.value ?? 0);
if (boxes >= boxesLeft) {
return { message: `Sorry, only ${boxesLeft} boxes are left.` };
}
cookieStore.set("boxes", String(boxes + 1));
return { message: "" };
}
2. Line 11 returns the message and the cookie stays at 2. When the transition ends, shownBoxes is boxes again, which is still 2. The recording showed In your cart: 2 boxes and Sorry, only 2 boxes are left.
This button only says Adding…. Change it so the count shows the new box as soon as the visitor clicks.
"use client";
import { useTransition } from "react";
import { addBox } from "./actions";
export default function AddBox({ boxes }: { boxes: number }) {
const [isPending, startTransition] = useTransition();
function add() {
startTransition(async () => {
await addBox();
});
}
return (
<div>
<p>In your cart: {boxes} {boxes === 1 ? "box" : "boxes"}</p>
<button onClick={add} disabled={isPending}>
{isPending ? "Adding…" : "Add a box"}
</button>
</div>
);
}
"use client";
import { useOptimistic, useState, useTransition } from "react";
import { addBox } from "./actions";
export default function AddBox({ boxes }: { boxes: number }) {
const [shownBoxes, setShownBoxes] = useOptimistic(boxes);
const [isPending, startTransition] = useTransition();
const [message, setMessage] = useState("");
function add() {
startTransition(async () => {
setShownBoxes(shownBoxes + 1);
const result = await addBox();
startTransition(() => setMessage(result.message));
});
}
return (
<div>
<p>In your cart: {shownBoxes} {shownBoxes === 1 ? "box" : "boxes"}</p>
<button onClick={add} disabled={isPending}>
{isPending ? "Adding…" : "Add a box"}
</button>
<p aria-live="polite">{message}</p>
</div>
);
}
useOptimistic(boxes) gives shownBoxes, which line 21 shows, and line 13 sets it inside the transition, before the action. The solution also keeps the action's message. Recorded on a slow network, this version said In your cart: 1 box right after the click; the pending-only version said 0 boxes.
Write the line that makes teas, so a new tea shows at once, marked as saving. What does the list show for Jasmine tea while the request is on its way?
"use client";
import { useOptimistic } from "react";
import { addToWishlist } from "./actions";
type Tea = { name: string; saving: boolean };
export default function Wishlist({ names }: { names: string[] }) {
const saved = names.map((name) => ({ name, saving: false }));
// Make teas, which shows a new tea at once, marked as saving.
async function addAction(formData: FormData) {
const name = String(formData.get("tea") ?? "").trim();
addTea(name);
await addToWishlist(name);
}
return (
<>
<ul>
{teas.map((tea) => (
<li key={tea.name}>{tea.saving ? `${tea.name} (saving…)` : tea.name}</li>
))}
</ul>
<form action={addAction}>
<label htmlFor="tea">Tea</label> <input id="tea" name="tea" /> <button>Add to wishlist</button>
</form>
</>
);
}
"use client";
import { useOptimistic } from "react";
import { addToWishlist } from "./actions";
type Tea = { name: string; saving: boolean };
export default function Wishlist({ names }: { names: string[] }) {
const saved = names.map((name) => ({ name, saving: false }));
const [teas, addTea] = useOptimistic(saved, (current: Tea[], name: string) => [...current, { name, saving: true }]);
async function addAction(formData: FormData) {
const name = String(formData.get("tea") ?? "").trim();
addTea(name);
await addToWishlist(name);
}
return (
<>
<ul>
{teas.map((tea) => (
<li key={tea.name}>{tea.saving ? `${tea.name} (saving…)` : tea.name}</li>
))}
</ul>
<form action={addAction}>
<label htmlFor="tea">Tea</label> <input id="tea" name="tea" /> <button>Add to wishlist</button>
</form>
</>
);
}
The reducer returns a new list with the tea marked saving: true, and line 22 shows that mark. The recording, on a slow network, showed Jasmine tea (saving…) until the server answered, then Jasmine tea and Saved teas: 1.
This version of the action throws when the shop has run out. The cart holds 2 boxes, and the visitor clicks Add a box. What does the visitor see after the answer?
"use server";
import { cookies } from "next/headers";
const boxesLeft = 2;
export async function addBox() {
const cookieStore = await cookies();
const boxes = Number(cookieStore.get("boxes")?.value ?? 0);
if (boxes >= boxesLeft) {
throw new Error(`Sorry, only ${boxesLeft} boxes are left.`);
}
cookieStore.set("boxes", String(boxes + 1));
return { message: "" };
}
The screen from app/teas/jasmine/error.tsx: The cart hit a problem. Reload the page to try again. It replaces the page, count and all. The recorded request answered 500. To keep the page and say why, return the message instead.
Check your understanding
For each question, ask which value comes from the server, and which one the browser shows while it waits.
Question 1 of 8The visitor clicks Add a box with no boxes in the cart. While the request is on its way, what is
shownBoxes?Choose an answer to see the explanation.
Question 2 of 8The transition has ended. Which value does
useOptimistic(boxes)return now?Choose an answer to see the explanation.
Question 3 of 8In this file, which component can call
useFormStatusand get the form's status?Read the code, then predictTSX"use client"; import { useFormStatus } from "react-dom"; import { addBox } from "./actions"; function AddButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? "Adding…" : "Add a box"}</button>; } export default function AddBox({ boxes }: { boxes: number }) { async function add() { await addBox(); } return ( <form action={add}> <p>In your cart: {boxes} {boxes === 1 ? "box" : "boxes"}</p> <AddButton /> </form> ); }Choose an answer to see the explanation.
Question 4 of 8A button's
onClickcalls a Server Action andsetShownBoxes. Where doessetShownBoxesbelong?Choose an answer to see the explanation.
Question 5 of 8The shop has 2 boxes, and the cart holds 2.
addBoxreturns a message. What does the page show after the answer?Choose an answer to see the explanation.
Question 6 of 8This time
addBoxthrows when the shop has run out. What does the visitor see?Choose an answer to see the explanation.
Question 7 of 8Why can
addActioncalladdTeawithoutstartTransition?Read the code, then predictTSX"use client"; import { useOptimistic } from "react"; import { addToWishlist } from "./actions"; type Tea = { name: string; saving: boolean }; export default function Wishlist({ names }: { names: string[] }) { const saved = names.map((name) => ({ name, saving: false })); const [teas, addTea] = useOptimistic(saved, (current: Tea[], name: string) => [...current, { name, saving: true }]); async function addAction(formData: FormData) { const name = String(formData.get("tea") ?? "").trim(); addTea(name); await addToWishlist(name); } return ( <> <ul> {teas.map((tea) => ( <li key={tea.name}>{tea.saving ? `${tea.name} (saving…)` : tea.name}</li> ))} </ul> <form action={addAction}> <label htmlFor="tea">Tea</label> <input id="tea" name="tea" /> <button>Add to wishlist</button> </form> </> ); }Choose an answer to see the explanation.
Question 8 of 8Which of these should wait for the server instead of showing its result at once?
Choose an answer to see the explanation.
Key takeaways
- A pending state says an action is running:
isPendingfromuseTransitionoruseActionState, orpendingfromuseFormStatusin a component inside the form. useOptimistic(value)returnsvalue, except while an action runs: then it returns what you set inside that action.- Call the optimistic setter inside an action:
startTransition, a form action, oruseActionState's action. Wrap state updates after anawaitinstartTransitiontoo. - When the action ends, the page shows the server's value: the new one if it worked, the old one if not. Rolling back needs no code.
- Return expected problems and show them; a thrown error replaces the page with the nearest
error.tsx. - Be optimistic about small changes that almost always work; wait for payments, bookings and anything that can't be undone.
Remember the one-liner.
Show it now, confirm it later, and let the server's answer win.
Coming next: Updating the UI after a mutation. It covers updateTag, revalidatePath, refresh() and redirect: the ways an action brings fresh data to the page.