Lesson 16 of 81
A form submit that waits for a full server round trip before showing anything feels sluggish. React's useOptimistic shows the guess instantly — the Server Action and cache revalidation underneath are what make that guess trustworthy instead of a lie.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
Question 1 of 3
A comment form needs the commenter's own new comment to appear the instant they reload the thread. Should the Server Action call `revalidateTag(tag, 'max')` or `updateTag(tag)`, and why?
Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A complete mutation has four moving parts, in order: a Server Action runs on the server and writes the change, it calls revalidateTag / updateTag / revalidatePath to invalidate whatever cached data is now stale, and the router re-renders with the confirmed fresh data. That's the trustworthy path — but it still involves a network round trip, which can feel slow for something as simple as posting a comment.
useOptimistic closes that gap on the client. It renders a predicted value immediately — before the Server Action has even started — for as long as the action is pending. Once the action settles and a new server render arrives, the optimistic value is discarded and the real, confirmed data takes its place. If the action fails instead of succeeding, there's nothing to swap in, so the optimistic value reverts on its own — no manual rollback code required, because it was never persisted state to begin with, just a temporary overlay.
// app/posts/[id]/actions.ts
"use server";
import { updateTag } from "next/cache";
export async function addComment(postId: string, formData: FormData) {
const content = formData.get("content") as string;
const comment = await db.comment.create({ data: { postId, content } });
updateTag(`comments-${postId}`); // the commenter needs to see it immediately
return comment;
}
// app/posts/[id]/comment-form.tsx
"use client";
import { useOptimistic, useRef } from "react";
import { addComment } from "./actions";
type Comment = { id: string; content: string; pending?: boolean };
export function CommentForm({ postId }: { postId: string }) {
const [optimisticComments, addOptimistic] = useOptimistic(
[] as Comment[],
(state, newComment: Comment) => [...state, newComment]
);
const formRef = useRef<HTMLFormElement>(null);
return (
<form
ref={formRef}
action={async (formData) => {
const content = formData.get("content") as string;
formRef.current?.reset();
addOptimistic({ id: crypto.randomUUID(), content, pending: true });
await addComment(postId, formData);
}}
>
<input name="content" required />
<button type="submit">Post</button>
{optimisticComments.map((c) => (
<p key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
{c.content}
</p>
))}
</form>
);
}
The user submits the form. useOptimistic renders the predicted comment on the client instantly, dimmed as pending — no server response yet.
addOptimistic runs synchronously on submit — the dimmed, pending comment appears instantly, before addComment has even reached the server. addComment then writes the row and calls updateTag, which guarantees the very next read for that tag is fresh rather than stale-while-revalidate — appropriate here because the one person who needs a guaranteed-fresh view is the commenter themselves. Once the action's promise resolves and the router re-renders with the real comment list, the optimistic entry is gone, replaced by the confirmed one.
useOptimistic never touches the cache. It's a purely client-side, temporary prediction. Without a revalidateTag/updateTag/revalidatePath call inside the Server Action, the underlying data source stays stale for the next real render and for every other user — the optimistic UI was never connected to it.updateTag vs. revalidateTag('tag', 'max') is the same tradeoff from the on-demand revalidation lesson, applied here. updateTag guarantees the mutating user's own next read is fresh; revalidateTag('max') just marks it stale for eventual background refresh. Using the read-your-own-writes tool for a delay-tolerant screen (or vice versa) either wastes a blocking fetch or shows someone stale data they just personally changed.useOptimistic needs to run inside a transition — automatic when it's a form's action, explicit via useTransition otherwise. Calling its setter outside a transition doesn't apply the optimistic value the way these examples show."How would you make a comment submission feel instant without lying to the user if it fails?" tests whether a candidate can explain the full loop — optimistic prediction, server mutation, cache invalidation, confirmed re-render, automatic rollback — rather than only knowing the client-side half or only the server-side half.