Skip to content
Chapter 44Lesson 5

Instant UI with useOptimistic

React 19's useOptimistic hook shows a mutation's result instantly and snaps it back if the server disagrees.

In the last two lessons you taught a form to wait. useActionState gives you isPending, and your <SubmitButton> reads it to disable itself and swap “Save” for a spinner. That fits 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 the UI freezes for 200ms while the action round-trips. That pause doesn’t read as “working on it”; it reads as broken, so they tap again. The user isn’t waiting for a result here. They already know what it will be, and they want to see it now.

This lesson covers that narrow but real set of mutations, and React 19’s hook for them: useOptimistic. We start with a rule for when an immediate update is worth it, because the default is still no optimism, and reaching for the hook on the wrong mutation creates a bug of its own. Then the mechanics: the hook, the transition it needs, and how it reconciles with the server. One idea holds the whole lesson together: the server owns the truth, and the client borrows the future for one render.

When to choose optimistic over pending state

Section titled “When to choose optimistic over pending state”

The skill is knowing the small set of mutations that deserve useOptimistic, not typing the hook. Reach for it on a payment form and you’ve learned the wrong thing.

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

  • High success rate. The mutation almost always succeeds: a flag flips, an item reorders, a notification is marked read. 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. An update nobody is 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.

Miss any one condition, and the mutation stays on the plain pending state from the last two lessons:

  • Failure-prone submits. Validation-heavy create and edit forms, and anything with cross-resource 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 behind a long undo window. The immediate frame buys no perceived speed because nobody’s looking.

None of this changes the action itself. Optimism is UX polish on a correct mutation: the Server Action, its Zod parse, the Result it returns, and the revalidatePath that refreshes the page are identical with or without an optimistic frame. That’s why it’s a JavaScript-only enhancement: strip the JS, and the action still runs.

Answer each question and see where the path lands.

Optimistic or pending?

To see the bookkeeping the hook deletes, here is the same optimistic toggle in plain useState: the user clicks “star,” you flip local state so the UI responds at once, you fire the action, and because it might fail you keep a snapshot to revert to.

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.

Snapshot, apply, revert on failure, revert on error: every line is plumbing, none of it is the feature. A list adds a fifth chore, reconciling your temporary id with the real one the server assigns. useOptimistic takes all of it. You stop writing how to undo and write only what the optimistic state should look like, as a pure function, then fire it.

Start with the simplest case: a single boolean, a “star this invoice” button with no <form>. No list keys, no FormData, just the hook’s shape and the one rule people get wrong.

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

Three pieces:

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

Now the rule that flips the model: in your JSX you read optimisticStarred, never invoice.starred. The optimistic value is the actual value, overlaid with any update in flight. Nothing in flight, it’s just the truth; something in flight, it’s the truth plus the borrowed future.

'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 (_, next) => next returns the next value, since that is a toggle’s optimistic state. addOptimisticStarred is the trigger we 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 and the await toggleStar(...) both run inside startTransition. 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>
);
};

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

addOptimistic only works inside a transition , and the optimistic value lives only as long as that transition is open. No transition, nowhere for it to live.

In the imperative case above you open the transition yourself with startTransition. Forms, covered next, open one automatically through the action prop, so you never call startTransition by hand.

Forget it and the failure is loud: a console warning, plus a visible flash you can catch by eye.

The toggle showed the shape. The real case is an “add comment” form where the new comment appears the instant the user submits: the reducer appends instead of replacing, list keys start to matter, and useOptimistic pairs with useActionState. The syntax is easy; the data flow is what to get right, so trace where the real state lives and when the optimistic comment falls away.

A Server Component reads the comments from the database and passes them to a Client Component as a comments prop: the server owns the data, props carry it across the boundary. The Client Component owns the optimism. It seeds useOptimistic with that prop, appends on submit, and lets React reconcile when a fresh comments prop arrives 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, a fresh comments prop flows back, and the optimism reconciles against it.

One form, two hooks, coordinated by React on the same submit. useActionState owns the submit lifecycle, pending, the Result, and the field errors, as in the last two lessons. useOptimistic owns the list during the in-flight window.

'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, owns the latest Result and the bound action. The <SubmitButton> reads pending on its own via useFormStatus, so the form root never destructures 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 without mutating: (current, newComment) => [...current, newComment]. 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 makes the optimistic update stick with no manual startTransition. The crypto.randomUUID() and formData.set('id', id) are 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; a pending: true comment renders dimmed (opacity-50), a “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. On failure the banner appears and the optimistic comment vanishes on its own: you write the banner, never the removal.

1 / 1

On failure, one Result does both jobs at once: state.ok flips to false so your banner renders, and React discards the optimistic overlay so the dimmed comment disappears. You write the banner; React handles the removal.

Reconciling the optimistic item: the id problem

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

React reconciles a list by key, and your keys are comment.id. The optimistic comment has no database id yet, so which id does it carry until the real one arrives? Two options.

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

No flicker, 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 a key, so React reconciles them in place. The codebase uses this client-UUID hidden-input pattern for optimistic mutations everywhere.

So the rule: generate a crypto.randomUUID(), a UUIDv7 by the course’s convention, on the client and hand it to the action as the entity’s id, so the optimistic and persisted rows share a key. The same UUID-by-key approach returns with TanStack Query’s optimistic mutations later.

An optimistic item can only carry data the client already has. Server-generated fields, like a createdAt timestamp or a computed total, don’t exist on the optimistic frame.

The optimistic value rolls back the same way on success and failure. When the surrounding transition settles, React discards the optimistic overlay and re-renders against actualState. One path, not two. What differs is only what the truth is at that moment:

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

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], no overlay, nothing in flight.

2 · Click submit. The transition opens and addOptimisticComment(C) fires. The user sees C immediately, dimmed, but actualState is still [A, B]: the overlay sits 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, C still dimmed, but 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.

5 · Failure (rewind to step 2, but the action fails). It returns ok: false, so actualState never left [A, B]. The transition settles, the overlay is discarded, and C vanishes, with zero rollback code from you. The banner is separate: it comes from state.ok === false.

Now wire it yourself. 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 on 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, with no flicker.

    Any <button onClick> mutation where the visual change matters follows the same three lines: open a transition, fire the optimistic update, await the action.

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

    The transition is what differs from the form case. A <form action={…}> or <button formAction={…}> opens it for you; an imperative onClick has no action prop, so you open it yourself. Forms are automatic; imperative handlers are manual.

    Two boundaries mark where optimism 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 works; it just skips the instant feedback.
    • For optimistic updates that live in a client cache, shared across views and rolled back into cached queries, reach for TanStack Query later in the course. useOptimistic is the native default; TanStack is the next step, only when the cache crosses views.