Skip to content
Chapter 44Lesson 5

Instant UI with useOptimistic

React 19's useOptimistic hook, the tool that makes a mutation's result show instantly and lets React snap it back if the server disagrees.

In the last two lessons you taught a form to be honest about waiting. useActionState hands you isPending, and your <SubmitButton> reads it to disable itself and swap “Save” for a spinner the moment the action fires. That’s the right answer for creating an invoice: the user filled out six fields, the server takes 400ms to write the row, and “Saving…” tells them their work is on its way.

Now picture a different click: a “star” toggle on an invoice, a “like” on a comment, a cart quantity stepper. The user taps once, and for 200ms the UI sits frozen while the action round-trips. To the user, that pause doesn’t read as “working on it”; it reads as broken, so they tap again. Pending state is the wrong fit here, because the user isn’t waiting for a result. They already know what the result will be, and they want to see it now.

This lesson is about that narrow but real set of mutations, and React 19’s hook for them: useOptimistic. You’ll leave with two things. The first is a decision rule for when an immediate update is worth it, because the 2026 default is still no optimism, and reaching for this hook on the wrong mutation creates a bug of its own. The second is the mechanics for the cases that pass that bar: the hook, the transition it needs, and how it reconciles with the server afterward. One idea holds the whole lesson together, so keep it in mind: the server owns the truth, and the client borrows the future for one render.

Start with the decision, not the hook. The skill that matters is knowing the small set of mutations that deserve useOptimistic before you know how to type the hook. A developer who can write the hook but reaches for it on a payment form has learned the wrong thing.

A mutation earns an optimistic update only when all three of these hold at once:

  • High success rate. The mutation almost always succeeds: a flag flips, an item reorders, a notification is marked read. Rollback should be a rare exception, not a routine outcome. If failure is common, you’re showing the user a result that frequently un-happens.
  • Visible to the user. The user’s eyes are on the thing that changes, at the moment they click it. Optimism only buys perceived performance if there’s a perception to improve. An update the user isn’t watching gains nothing from arriving a few hundred milliseconds early.
  • Small UI change. A boolean toggles, a row appends, a count increments. Each is cheap to render optimistically, and cheap to snap back if the server disagrees.

Now the other side, the mutations you keep on the plain pending state from the last two lessons:

  • Failure-prone submits. These are validation-heavy create and edit forms, and anything with cross-resource business rules like plan limits or uniqueness checks. Here a rollback feels like a bug: the user typed real data, watched it appear, then watched it vanish. Show them the spinner and the field errors instead.
  • Payments, irreversible, or high-stakes. Never optimistic. The user must see the real confirmation. “It looks like it worked” is not good enough when money moved.
  • Results the user isn’t watching. A background autosave, or a delete sitting behind a long undo window. The immediate frame buys no perceived speed because nobody’s looking at it.

Here is the formula worth memorizing: high success rate, visible to the user, and small UI change together mean optimistic. Miss any one, and you want pending state instead.

One thing this doesn’t change is the action itself. Optimism is UX polish layered on top of a correct mutation. The Server Action, its Zod parse, the Result it returns, and the revalidatePath that refreshes the page are all identical whether or not you add an optimistic frame on top. We’ll come back to that at the end of the chapter, because it’s also why optimism is a JavaScript-only enhancement: strip the JS, and the action still runs.

Walk the decision in the order an experienced engineer asks the questions. The exercise below is that walk: answer each question in turn and see where the path lands.

Optimistic or pending?

Before the hook, look at what it replaces. Once you’ve felt the bookkeeping it deletes, useOptimistic lands as relief rather than one more API to memorize.

Here’s the pre-React-19 approach for an optimistic toggle, in plain useState. The user clicks “star,” you flip local state immediately so the UI responds, and you fire the action. Because the action might fail, you also keep a snapshot so you can revert. For a list it’s worse: you snapshot the whole array, append a temporary item, and on success swap the temp id for the real one or refetch the list.

by-hand optimistic toggle (the old way)
const [starred, setStarred] = useState(invoice.starred);
const onToggle = async () => {
const previous = starred; // snapshot
setStarred(!starred); // apply
try {
const result = await toggleStar(invoice.id);
if (!result.ok) {
setStarred(previous); // revert on failure
}
} catch {
setStarred(previous); // revert on error
}
};
// A list is worse: snapshot the array, append a temp item, then
// reconcile temp → real on success.

Read the comments, not the logic: snapshot, apply, revert on failure, revert on error. Every one of those is plumbing. None of it is the feature, which is simply to show the star filled. The list case adds a fifth chore on top: reconciling your made-up temporary id with the real id the server assigns.

This is exactly the burden useOptimistic removes. React takes ownership of the snapshot, the apply, and both revert paths. You stop writing how to undo. You write only what the optimistic state should look like, as a pure function, and fire it. React handles the rest.

Meet the hook on the simplest possible case: a single boolean, in a “star this invoice” button with no <form> involved. One boolean means no list keys, no FormData, and nothing competing for your attention while you learn the shape and the one rule that trips everyone.

The signature is two lines:

const [optimisticStarred, addOptimisticStarred] = useOptimistic(
invoice.starred,
(_current, next: boolean) => next,
);

Three pieces:

  • actualState is the first argument, here invoice.starred. This is the server-confirmed truth. It arrives as a prop from a Server Component that read it from the database. This is the boundary: the server owns this value.
  • reducer is the second argument, (current, optimisticValue) => nextState. It’s a pure function that computes the optimistic state from the current value and whatever you pass to addOptimistic. For a toggle there’s nothing to combine, since the next value is the optimistic state, so it’s (_, next) => next.
  • addOptimistic(value) is the function you call to apply an optimistic update. The catch, which we’ll return to in a moment, is that it must be called inside a transition.

Here’s the part that flips the mental model: in your JSX you read optimisticStarred, never invoice.starred. The optimistic value is the actual value, overlaid with any update currently in flight. When nothing’s in flight, it’s just the truth. When something is, it’s the truth plus the borrowed future.

Here’s the button end to end. Step through it.

'use client';
import { useOptimistic, startTransition } from 'react';
import { toggleStar } from './actions';
export const StarButton = ({ invoice }: { invoice: Invoice }) => {
const [optimisticStarred, addOptimisticStarred] = useOptimistic(
invoice.starred,
(_current, next: boolean) => next,
);
const onToggle = () => {
startTransition(async () => {
addOptimisticStarred(!optimisticStarred);
await toggleStar(invoice.id);
});
};
return (
<button
onClick={onToggle}
aria-pressed={optimisticStarred}
className={optimisticStarred ? 'text-amber-500' : 'text-muted-foreground'}
>
<Star fill={optimisticStarred ? 'currentColor' : 'none'} />
</button>
);
};

The hook call. actualState is invoice.starred, the server’s truth, passed as a prop. The reducer is (_, next) => next, because a toggle’s optimistic state is simply the next value. addOptimisticStarred is the trigger we’ll fire on click.

'use client';
import { useOptimistic, startTransition } from 'react';
import { toggleStar } from './actions';
export const StarButton = ({ invoice }: { invoice: Invoice }) => {
const [optimisticStarred, addOptimisticStarred] = useOptimistic(
invoice.starred,
(_current, next: boolean) => next,
);
const onToggle = () => {
startTransition(async () => {
addOptimisticStarred(!optimisticStarred);
await toggleStar(invoice.id);
});
};
return (
<button
onClick={onToggle}
aria-pressed={optimisticStarred}
className={optimisticStarred ? 'text-amber-500' : 'text-muted-foreground'}
>
<Star fill={optimisticStarred ? 'currentColor' : 'none'} />
</button>
);
};

The click handler, and the load-bearing part. addOptimisticStarred runs inside startTransition, and the await toggleStar(...) lives in the same transition. Fire the optimistic update first so the UI flips instantly, then await the real action.

'use client';
import { useOptimistic, startTransition } from 'react';
import { toggleStar } from './actions';
export const StarButton = ({ invoice }: { invoice: Invoice }) => {
const [optimisticStarred, addOptimisticStarred] = useOptimistic(
invoice.starred,
(_current, next: boolean) => next,
);
const onToggle = () => {
startTransition(async () => {
addOptimisticStarred(!optimisticStarred);
await toggleStar(invoice.id);
});
};
return (
<button
onClick={onToggle}
aria-pressed={optimisticStarred}
className={optimisticStarred ? 'text-amber-500' : 'text-muted-foreground'}
>
<Star fill={optimisticStarred ? 'currentColor' : 'none'} />
</button>
);
};

The JSX reads optimisticStarred, never invoice.starred. aria-pressed, the color class, and the icon fill all key off the optimistic value, so the star flips the instant the user clicks, before the server has done anything.

'use client';
import { useOptimistic, startTransition } from 'react';
import { toggleStar } from './actions';
export const StarButton = ({ invoice }: { invoice: Invoice }) => {
const [optimisticStarred, addOptimisticStarred] = useOptimistic(
invoice.starred,
(_current, next: boolean) => next,
);
const onToggle = () => {
startTransition(async () => {
addOptimisticStarred(!optimisticStarred);
await toggleStar(invoice.id);
});
};
return (
<button
onClick={onToggle}
aria-pressed={optimisticStarred}
className={optimisticStarred ? 'text-amber-500' : 'text-muted-foreground'}
>
<Star fill={optimisticStarred ? 'currentColor' : 'none'} />
</button>
);
};

What happens on settle. When await toggleStar resolves or rejects, React discards the optimistic overlay and re-renders against actualState. On success that prop has already refreshed, through the action’s revalidatePath, so the star stays filled. On failure actualState never changed, so the star snaps back, with zero rollback code.

1 / 1

Look at what you didn’t write. No snapshot, no try/catch, no revert. The handler fires the optimistic value and awaits the action, and that’s it. React owns the undo.

Now for the rule mentioned earlier, since it’s the single thing that decides whether your optimistic update sticks.

addOptimistic only works inside a transition , a React update that React is allowed to interrupt and reorder against more urgent work. Optimistic state is tied to that transition’s lifecycle: when there’s no transition open, there’s no place for the optimistic value to live.

For the imperative case above, you open the transition yourself with startTransition. For forms, covered in the next section, the action prop opens one for you automatically, so you don’t call startTransition by hand. That difference is the whole trap: forms are automatic, and imperative handlers are manual.

What happens if you forget? This isn’t one of the chapter’s silent traps, like passing the raw action instead of the bound one. This one warns in the console, but the visible symptom is the clearer tell.

The toggle taught you the shape. Now move to the case you’ll actually ship: an “add comment” form where the new comment appears in the list the instant the user hits submit. This is where the reducer appends instead of replacing, where list keys start to matter, and where useOptimistic pairs with the useActionState you already know.

Before the code, look at the data flow, because this is the part that genuinely trips people, more than any syntax. The question students get stuck on is this: where does the actual state come from, and why does the optimistic comment fall away at exactly the right moment?

Here’s the boundary. The list of comments is rendered by a Server Component that reads them from the database. Those comments cross into a Client Component as a comments prop. This is the same discipline from earlier in the course, where the Server Component owns the data and props carry it across the boundary. The Client Component owns the optimism: it seeds useOptimistic with that comments prop, appends to it on submit, and lets React reconcile when a fresh comments prop flows in after the action revalidates.

Server Component
DB

Reads comments from the database — the truth.

On the boundary
comments

The serialized array, passed as a prop from server to client.

Client Component
useOptimistic(comments, …)

Seeds actualState borrows the future for one render.

The server owns the truth, and the client borrows the future for one render. After the action, revalidatePath re-renders the server and a fresh comments prop flows back, and the optimism reconciles against it.

The component runs two hooks for one form. useActionState owns the submit lifecycle, meaning pending, the Result, and the field errors, exactly as in the last two lessons. useOptimistic owns the list during the in-flight window. Both fire on the same submit, and React coordinates them. Step through it.

'use client';
import { useActionState, useOptimistic } from 'react';
import { addComment } from './actions';
export const CommentThread = ({
invoiceId,
comments,
}: {
invoiceId: string;
comments: Comment[];
}) => {
const [state, formAction] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newComment: Comment) => [...current, newComment],
);
const onSubmit = (formData: FormData) => {
const id = crypto.randomUUID();
addOptimisticComment({
id,
body: String(formData.get('body')),
pending: true,
});
formData.set('id', id);
return formAction(formData);
};
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
{comment.body}
</li>
))}
</ul>
<form action={onSubmit}>
<input type="hidden" name="invoiceId" value={invoiceId} />
<textarea name="body" required />
{state?.ok === false && <p role="alert">{state.error.userMessage}</p>}
<SubmitButton>Comment</SubmitButton>
</form>
</div>
);
};

useActionState(addComment, null), the same hook from the last lessons. It owns the latest Result and the bound action. The <SubmitButton> reads pending on its own via useFormStatus, so the form root doesn’t destructure isPending. This is the failure-path half of the pairing.

'use client';
import { useActionState, useOptimistic } from 'react';
import { addComment } from './actions';
export const CommentThread = ({
invoiceId,
comments,
}: {
invoiceId: string;
comments: Comment[];
}) => {
const [state, formAction] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newComment: Comment) => [...current, newComment],
);
const onSubmit = (formData: FormData) => {
const id = crypto.randomUUID();
addOptimisticComment({
id,
body: String(formData.get('body')),
pending: true,
});
formData.set('id', id);
return formAction(formData);
};
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
{comment.body}
</li>
))}
</ul>
<form action={onSubmit}>
<input type="hidden" name="invoiceId" value={invoiceId} />
<textarea name="body" required />
{state?.ok === false && <p role="alert">{state.error.userMessage}</p>}
<SubmitButton>Comment</SubmitButton>
</form>
</div>
);
};

useOptimistic(comments, reducer). The actualState is the comments prop, the server truth. The reducer appends: (current, newComment) => [...current, newComment], pure and with no mutation. This is the in-flight half.

'use client';
import { useActionState, useOptimistic } from 'react';
import { addComment } from './actions';
export const CommentThread = ({
invoiceId,
comments,
}: {
invoiceId: string;
comments: Comment[];
}) => {
const [state, formAction] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newComment: Comment) => [...current, newComment],
);
const onSubmit = (formData: FormData) => {
const id = crypto.randomUUID();
addOptimisticComment({
id,
body: String(formData.get('body')),
pending: true,
});
formData.set('id', id);
return formAction(formData);
};
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
{comment.body}
</li>
))}
</ul>
<form action={onSubmit}>
<input type="hidden" name="invoiceId" value={invoiceId} />
<textarea name="body" required />
{state?.ok === false && <p role="alert">{state.error.userMessage}</p>}
<SubmitButton>Comment</SubmitButton>
</form>
</div>
);
};

The submit handler does both jobs. It fires addOptimisticComment so the comment shows instantly, then calls the bound formAction, whose automatic transition is what lets the optimistic update stick (so there’s no manual startTransition here). The crypto.randomUUID() and formData.set('id', id) are the reconcile setup, covered next.

'use client';
import { useActionState, useOptimistic } from 'react';
import { addComment } from './actions';
export const CommentThread = ({
invoiceId,
comments,
}: {
invoiceId: string;
comments: Comment[];
}) => {
const [state, formAction] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newComment: Comment) => [...current, newComment],
);
const onSubmit = (formData: FormData) => {
const id = crypto.randomUUID();
addOptimisticComment({
id,
body: String(formData.get('body')),
pending: true,
});
formData.set('id', id);
return formAction(formData);
};
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
{comment.body}
</li>
))}
</ul>
<form action={onSubmit}>
<input type="hidden" name="invoiceId" value={invoiceId} />
<textarea name="body" required />
{state?.ok === false && <p role="alert">{state.error.userMessage}</p>}
<SubmitButton>Comment</SubmitButton>
</form>
</div>
);
};

The list maps over optimisticComments, not comments. Each row is keyed by comment.id, and a pending: true comment renders dimmed (opacity-50), a subtle “sending…” affordance while the action is in flight.

'use client';
import { useActionState, useOptimistic } from 'react';
import { addComment } from './actions';
export const CommentThread = ({
invoiceId,
comments,
}: {
invoiceId: string;
comments: Comment[];
}) => {
const [state, formAction] = useActionState(addComment, null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(current, newComment: Comment) => [...current, newComment],
);
const onSubmit = (formData: FormData) => {
const id = crypto.randomUUID();
addOptimisticComment({
id,
body: String(formData.get('body')),
pending: true,
});
formData.set('id', id);
return formAction(formData);
};
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.pending ? 'opacity-50' : ''}>
{comment.body}
</li>
))}
</ul>
<form action={onSubmit}>
<input type="hidden" name="invoiceId" value={invoiceId} />
<textarea name="body" required />
{state?.ok === false && <p role="alert">{state.error.userMessage}</p>}
<SubmitButton>Comment</SubmitButton>
</form>
</div>
);
};

The error read. state?.ok === false gates the banner exactly as before. On failure this banner appears and the optimistic comment vanishes automatically: you write the banner, never the removal.

1 / 1

Sit with that division of labor, because it’s the mental model that makes the pairing click. useActionState owns the failure path the user reads: the pending state and the error banner. useOptimistic owns the list during the in-flight window. On a failure, two things happen from one Result: useActionState flips state.ok to false so your banner renders, and React discards the optimistic overlay so the dimmed comment disappears. The form layer writes the banner. It never writes the removal.

Reconciling the optimistic item: the id problem

Section titled “Reconciling the optimistic item: the id problem”

There’s a quiet problem hiding in that submit handler. React reconciles a list by key, and your keys are comment.id. But the optimistic comment has no database id yet, because the server hasn’t assigned one. So what id does the optimistic item carry, and what happens when the real one arrives?

You have two options. Compare them.

const id = crypto.randomUUID();
addOptimisticComment({ id, body, pending: true });
formData.set('id', id); // the action persists THIS id

No flicker, and the recommended default. Generate the id on the client and pass it to the action, which persists that exact id. The optimistic item and the server-returned row share the same key, so React reconciles them in place. This is the same client-UUID hidden-input pattern the codebase reuses for optimistic mutations everywhere.

The rule: a client-generated UUID is the default, and temp ids are the prototype shortcut. You generate a crypto.randomUUID(), a UUIDv7 in the course’s convention, on the client and hand it to the action as the entity’s id. That means the optimistic row and the persisted row are the same row as far as React’s reconciler is concerned. They share a key, so the swap is seamless. This same UUID-by-key approach comes back when you reach for TanStack Query’s cached optimistic mutations later in the course, so it’s worth getting into your fingers now.

One thing to watch lives right here, with reconciling. An optimistic item can only carry data the client has. Fields the server generates, such as a createdAt timestamp or a computed total, don’t exist on the optimistic frame.

This is the headline, named in the lesson title, and it’s the part developers most often misremember. Does it roll back on error only? What about on success? Let’s make it precise.

The rule, stated plainly: when the surrounding transition settles, on success or failure alike, React discards the optimistic overlay and re-renders against actualState. That’s the whole mechanism. There are not two code paths, only one: render the truth.

What differs between success and failure is only what the truth is at that moment:

  • On success, actualState has already moved. The action’s revalidatePath triggered a fresh server render, the new comments prop flowed into the component, and it now includes the real comment. The optimistic overlay falls away and the real item is already sitting there, seamless because the keys match.
  • On failure, actualState never moved. The mutation didn’t commit, so the comments prop is unchanged. The overlay falls away and the list is exactly what it was before the click. You read state.ok === false to show the user why it failed, but the removal of the optimistic item is automatic.

That’s the point worth internalizing: “rollback” isn’t a special operation React performs. It’s the natural consequence of re-rendering against an actualState that, on failure, never changed. There’s nothing to undo, because nothing real was ever done.

Scrub through the full lifecycle below. Each step shows the comment list, what actualState holds, and whether an optimistic overlay is applied.

1 · Idle. The list renders the comments prop, the server truth. actualState is [A, B] and no optimistic overlay is applied. Nothing is in flight.

2 · Click submit. The transition opens and addOptimisticComment(C) fires. The user sees C immediately, rendered dimmed, but actualState is still [A, B]. The optimistic overlay is laid on top of the truth, not merged into it.

3 · Action in flight. The Server Action is parsing and writing. The borrowed future is on screen, with C still dimmed, but the truth hasn’t caught up yet. actualState is unchanged.

4 · Success. revalidatePath refreshes the server render, so actualState becomes [A, B, C]. The transition settles and React discards the overlay. Because optimistic C and real C share a key, C snaps solid in place with no flicker. We’re now rendering the truth.

5 · Failure (rewind to step 2, but this time the action fails). The action returns ok: false, so actualState never moved from [A, B]. The transition settles, the overlay is discarded, and C simply vanishes. “Rollback” is nothing more than re-rendering the unchanged truth, with zero rollback code from you. The banner is separate: it comes from state.ok === false.

Now fix the timeline in memory. Drag the steps below into the order they actually happen.

Order the lifecycle of an optimistic comment add, from the user's click to the final render. Drag the items into the correct order, then press Check.

The list renders the server’s confirmed comments.
The user submits; addOptimisticComment fires inside the form’s transition.
The optimistic comment renders immediately, dimmed.
The Server Action runs and either commits or fails.
revalidatePath refreshes actualState on success, or actualState stays unchanged on failure.
The transition settles and React discards the optimistic overlay.
React re-renders against actualState — the real comment on success, the original list on failure.

Time to wire it yourself. Below is a “star” button that updates only after its action resolves, the frozen feel from the top of the lesson. Your job is to add useOptimistic and a transition so the star flips the instant it’s clicked.

Click the star: nothing happens for ~600ms, then it fills. That pause is the frozen feel. Make the star flip the instant it's clicked. Add useOptimistic seeded from the starred state with the toggle reducer (_, next) => next, then in onToggle wrap the work in startTransition: fire the optimistic update first, await toggleStar, and commit the result with setStarred. Read the optimistic value in aria-pressed and the label. The star should flip immediately — before toggleStar resolves — and stay flipped after.

Preview
    Reference solution
    import { useState, useOptimistic, startTransition } from 'react';
    const toggleStar = (current: boolean): Promise<boolean> =>
    new Promise((resolve) => setTimeout(() => resolve(!current), 600));
    export function App() {
    const [starred, setStarred] = useState(false);
    const [optimisticStarred, addOptimisticStarred] = useOptimistic(
    starred,
    (_current, next: boolean) => next,
    );
    const onToggle = () => {
    startTransition(async () => {
    addOptimisticStarred(!optimisticStarred);
    const next = await toggleStar(optimisticStarred);
    setStarred(next);
    });
    };
    return (
    <button
    onClick={onToggle}
    aria-pressed={optimisticStarred}
    className="rounded border px-3 py-1"
    >
    {optimisticStarred ? '★ Starred' : '☆ Star'}
    </button>
    );
    }

    startTransition opens the transition addOptimisticStarred needs, so the optimistic value renders the instant you click and holds for the whole in-flight window. The JSX reads optimisticStarred, never starred. When toggleStar resolves, setStarred commits the truth, and the overlay falls away onto a matching actualState, so the star stays filled with no flicker.

    That practice button was the non-form shape, and it’s worth naming so you carry the pattern forward. Any <button onClick> mutation where the visual change matters follows the same three lines: open a transition, fire the optimistic update, and await the action.

    const onClick = () => {
    startTransition(async () => {
    addOptimistic(next);
    await action();
    });
    };

    Hold this next to the form case, because the contrast is the one rule that decides whether your optimistic update sticks. With a <form action={…}>, or a <button formAction={…}>, React opens the transition for you, and you never call startTransition by hand. With an imperative onClick, there’s no action prop, so you supply the transition. Forms are automatic, and imperative handlers are manual. Forget the manual case and you’ll see the flash and revert from earlier.

    That closes out the chapter’s form toolkit. Here’s a quick map of where optimism sits and where it hands off:

    • It’s a JavaScript-only enhancement. Without JS the optimistic frame never renders, but the action still runs, and the server render after revalidatePath shows the result. The form keeps working; it just skips the instant feedback. The full no-JS story is the last lesson of this chapter.
    • For optimistic updates that live in a client cache, shared across views and rolled back into cached queries, that’s TanStack Query’s job, later in the course. useOptimistic is the native default, and TanStack is the next step past it, reached for only when the cache crosses views.

    You now have the whole picture: pending state for the honest wait, and optimistic state for the borrowed future. The server owns the truth, and the client borrows it for one render. React snaps back when the truth returns, whether the action succeeds or fails, and without a line of rollback code from you.