cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Show that an action is runningPick between useTransition, useActionState and useFormStatus for a button's pending state.
  • 02
    Show 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.
  • 03
    Handle 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.

The Jasmine tea page, with an optimistic cartNext.js
app/teas/jasmine/
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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/jasmineTea shop
Jasmine tea

$5 a box. Only 2 boxes are left.

In your cart: 1 box

Adding…

Total: $0

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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.

Three browser windows in a row. Before: 0 boxes, an Add a box button, Total $0. After the click, while waiting: 1 box, shaded because it's the optimistic value, the button says Adding…, and the total is still $0. After the server answers: 1 box, Add a box, Total $5.before0 boxesAdd a boxTotal: $0all from the serverwaiting1 boxAdding…Total: $01 box: optimisticanswer1 boxAdd a boxTotal: $5all from the serverclickanswer
After the click, the count shows the new box at once, and the button says 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.
Real-life analogyThe order board at a café

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
The main idea

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:

Playground: three ways to say Adding…
The Jasmine tea page, with this version of app/teas/jasmine/add-box.tsxNext.js
app/teas/jasmine/
app/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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/jasmineTea shop
Jasmine tea

$5 a box. Only 2 boxes are left.

In your cart: 0 boxes

Adding…

Total: $0

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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
Try it yourself
useTransition

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….

Every version was built with next build and used in real Chrome: open /teas/jasmine, make the network slow, click Add a box. The Steps tab opens on the page while the request was on its way; step 4 is after the answer. Only app/teas/jasmine/add-box.tsx changes. Reset goes back to the first version.

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:

Three hooks for a pending state
HookWhere you call itWhat it gives you
useTransitionIn the component with the button. You start the action yourself, in startTransition.isPending, true until the action and the updates it starts are done.
useActionStateIn 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.
useFormStatusIn 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: useTransition is the simplest, as the optimistic cart above shows. useActionState works there too, if you call its action inside startTransition; only useFormStatus needs a form around it.
  • A form whose answer you show: useActionState. You already have it for the answer, so its isPending costs 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:

app/teas/jasmine/add-box.tsx: the count shows the new box at onceTSX
"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's boxes, 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:

Playground: what does the page say while the box is on its way?
The Jasmine tea page, with this version of app/teas/jasmine/add-box.tsxNext.js
app/teas/jasmine/
app/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>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/jasmineTea shop
Jasmine tea

$5 a box. Only 2 boxes are left.

In your cart: 0 boxes

Adding…

Total: $0

Focus
nothing: the page itself
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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
Try it yourself
Pending only

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.

Both versions were built with next build and used in real Chrome on a slow network. The Steps tab opens on the page right after the click on Add a box, while the request was on its way; step 4 is after the answer. Reset goes back to the first version.
Two halves. While the action runs: boxes holds 0, and shownBoxes holds 1, shaded because it is optimistic. After the transition ends: boxes holds 1, and shownBoxes holds 1 too.While the action runsboxes0shownBoxes1from the serveron the pageAfter the transition endsboxes1shownBoxes1from the serveron the page
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:

boxes and shownBoxes, step by step
Momentboxes, from the servershownBoxes, on the pageThe total
Before the first click00$0
While the first box is on its way01$0
After the server's answer11$5
While the third box is on its way23$10
After the server says no22$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:

  1. Line 13 sets the optimistic count, inside the transition.
  2. Line 14 calls the Server Action and awaits its answer.
  3. Line 15 stores the action's message. It's wrapped in startTransition again, because React doesn't count a state update after an await as 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's formAction, 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:

app/teas/jasmine/actions.ts: no box when the shop has run outTSX
"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.

Three cards in a row: 2 boxes, then 3 boxes, shaded because it's optimistic, then 2 boxes again. Under the last one, a card with the message Sorry, only 2 boxes are left.2 boxesthe server's countclick3 boxesshown at onceno2 boxesthe server's count againSorry, only 2 boxes are left.the message the action returned
The page shows 3 boxes as soon as the visitor clicks. The server says no and changes nothing, so when the transition ends, the count is the server's again, 2, and the message explains why.

Step through both trips, the yes and the no. Before you start, guess: in which step does the no reach the page?

Playground: who does what when the visitor adds a box
Who does whatThe server says yes
What the visitor seesafter step 1
tea-shop.example/teas/jasmine

Jasmine 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.

Step 1 of 6
Browser: The visitor clicks Add a box

The cart is empty. The click runs add, on line 11 of add-box.tsx.

Each step is one thing that happens, in order. Every screen is a page real Chrome showed in the recording of the cart above: before the click, while the request was on its way, and after the answer. The steps between happened on the network and the server, so the screen doesn't change. Reset goes back to the first box's first step.

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:

Playground: return the problem, or throw it?
The Jasmine tea page, with this version of app/teas/jasmine/actions.tsNext.js
app/teas/jasmine/
app/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: "" };}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/teas/jasmineTea shop
Jasmine tea

$5 a box. Only 2 boxes are left.

In your cart: 2 boxes

Add a box

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

Try it yourself
Returns a message

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.

Both versions were built with next build and used in real Chrome: two boxes added, then a third on a slow network. The Steps tab opens after the answer to the third; step 8 is the page while it was on its way. Only app/teas/jasmine/actions.ts changes. Reset goes back to the first version.

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:

A wishlist that shows the new tea, marked as savingNext.js
app/wishlist/
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>    </>  );}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/wishlistTea shop
Your wishlist

Saved teas: 0

  • Jasmine tea (saving…)
Jasmine tea Add to wishlist
Focus
the button “Add to wishlist”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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:

Show the result at once, or wait for the server?
  • 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.
Try it yourself
0 of 6 correct

Sort each action: is it safe to show its result before the server answers?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 of useActionState.
  • "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.

Exercise 1 · Warm-upRead the page while the box is on its way

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?

Starter codeTSX
"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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPick the pending hook

    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?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticePredict the count after a no

      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?

      Starter codeTSX
      "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: "" };
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeMake the count optimistic

        This button only says Adding…. Change it so the count shows the new box as soon as the visitor clicks.

        Starter codeTSX
        "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>
          );
        }
        
          Exercise 5 · ChallengeWrite the wishlist's reducer

          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?

          Starter codeTSX
          "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>
              </>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeThrow it, and see what's left

            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?

            Starter codeTSX
            "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: "" };
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which value comes from the server, and which one the browser shows while it waits.

              The Pending and optimistic UI quiz · 8 questionsScore: first tries count
              1. 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.

              2. Question 2 of 8The transition has ended. Which value does useOptimistic(boxes) return now?

                Choose an answer to see the explanation.

              3. Question 3 of 8In this file, which component can call useFormStatus and 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.

              4. Question 4 of 8A button's onClick calls a Server Action and setShownBoxes. Where does setShownBoxes belong?

                Choose an answer to see the explanation.

              5. Question 5 of 8The shop has 2 boxes, and the cart holds 2. addBox returns a message. What does the page show after the answer?

                Choose an answer to see the explanation.

              6. Question 6 of 8This time addBox throws when the shop has run out. What does the visitor see?

                Choose an answer to see the explanation.

              7. Question 7 of 8Why can addAction call addTea without startTransition?

                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.

              8. 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: isPending from useTransition or useActionState, or pending from useFormStatus in a component inside the form.
              • useOptimistic(value) returns value, 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, or useActionState's action. Wrap state updates after an await in startTransition too.
              • 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.

              CompleteFrontend Clear concepts. Working examples.