Loading states, errors and optimistic updates
The gap between tapping a button and the server answering is where a shop feels fast or slow. This lesson is about what to show during it, and how to make the common actions feel instant without lying to the user.
Where to fetch
From module 3, restated as a decision table:
| Situation | Where |
|---|---|
| Page content on first load | server component |
| Anything crawlers must see | server component |
| After a form submission | server action |
| After an interaction — add to cart | server action or client fetch |
| Data that changes while watching | client, polling or a library |
| Search-as-you-type | client |
Default to the server. Reach for a client fetch when the data is a response to an interaction, not a response to navigation.
Loading states that do not feel slow
Three levels, and choosing the right one matters more than any optimisation.
Under about 100ms — show nothing. A spinner that flashes for one frame looks like a glitch.
100ms to 1 second — a skeleton. From module 4, matching the real layout so nothing jumps.
Over a second — a skeleton and, if you can, progress. At the very least something that tells the user the system is working.
// apps/web/src/app/(shop)/products/loading.tsx
export default function Loading() {
return <ProductGridSkeleton count={8} />;
}
Next.js shows it automatically while the route's data loads.
Stream the slow parts rather than waiting for everything:
export default async function ProductPage({ params }: Props) {
const product = await getProduct((await params).slug);
return (
<>
<ProductDetail product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews slug={product.slug} />
</Suspense>
</>
);
}
The product appears immediately; reviews arrive when ready. Put what the user came for outside the boundary and everything secondary inside it.
Optimistic updates
For actions that almost always succeed, update the interface immediately and reconcile afterwards.
"use client";
import { useOptimistic, useTransition } from "react";
import { updateCartQuantity } from "@/app/actions/cart";
export function QuantityStepper({ item }: { item: CartItem }) {
const [isPending, startTransition] = useTransition();
const [optimisticQuantity, setOptimisticQuantity] = useOptimistic(item.quantity);
function change(next: number) {
if (next < 1 || next > MAX_CART_QUANTITY) return;
startTransition(async () => {
setOptimisticQuantity(next);
await updateCartQuantity(item.id, next);
});
}
return (
<div className="flex items-center gap-3" aria-busy={isPending}>
<button onClick={() => change(optimisticQuantity - 1)} aria-label="Decrease">−</button>
<span className="tabular-nums">{optimisticQuantity}</span>
<button onClick={() => change(optimisticQuantity + 1)} aria-label="Increase">+</button>
</div>
);
}
The number changes on tap. The server call happens behind it, and useOptimistic
automatically reverts to the real value if the action fails or returns something
different.
setOptimisticQuantity must be inside startTransition. Outside it, React
throws — the optimistic value only exists for the duration of a transition.
When not to be optimistic
Optimism is a promise. Break it and the interface is lying.
Do not be optimistic about:
- Payments. Never show "paid" before the gateway confirms.
- Stock at checkout. "Order placed" followed by "actually, out of stock" is much worse than a two-second wait.
- Anything irreversible. Deleting an account, cancelling an order.
- Anything that frequently fails. If one in five attempts fails, the revert is the normal experience.
Be optimistic about: cart quantities, favourites, marking something read. Cheap, reversible, and nearly always successful.
The test: if this fails, is the revert embarrassing? Cart quantity snapping back is mildly odd. "Order confirmed" turning into an error is a support call.
Adding to cart
"use client";
export function AddToCartButton({ variant }: { variant: VariantSummary }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function add() {
setError(null);
startTransition(async () => {
const result = await addToCart(variant.id, 1);
if (result?.error) setError(result.error);
});
}
return (
<div>
<Button
onClick={add}
disabled={!variant.inStock}
aria-busy={isPending}
className="w-full"
>
{!variant.inStock ? "Out of stock" : isPending ? "Adding…" : "Add to cart"}
</Button>
{error && <p role="alert" className="mt-2 text-sm text-red-600">{error}</p>}
</div>
);
}
Not optimistic, because adding to cart can genuinely fail on stock. The pending state is brief and honest.
disabled is fine here — this is a button, not a form submit, so the module 4
warning does not apply.
The server action revalidates so the header count updates:
"use server";
export async function addToCart(variantId: string, quantity: number) {
const user = await getCurrentUser();
if (!user) return { error: "Please sign in to add items." };
try {
await api.cart.addItem({ variantId, quantity });
revalidatePath("/cart");
revalidateTag("cart");
return { success: true };
} catch (error) {
if (error instanceof ApiError && error.code === "INSUFFICIENT_STOCK") {
return { error: error.message };
}
return { error: "Could not add that item. Please try again." };
}
}
Return errors rather than throwing, so the button can show them without losing the page — module 3's rule.
Translate known error codes into their real messages and fall back to a
generic one for everything else. The code field from module 7 is what makes
that possible without matching on message text.
Search as you type
"use client";
export function ProductSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const debounced = useDebounced(query, 300);
useEffect(() => {
const params = new URLSearchParams(searchParams);
if (debounced.trim()) {
params.set("q", debounced.trim());
} else {
params.delete("q");
}
params.delete("page");
router.replace(`/products?${params}`, { scroll: false });
}, [debounced]);
return (
<Input
label="Search products"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
Four decisions here.
Debounce 300ms, so typing "tomato" is one request rather than six.
router.replace, not push, or every keystroke becomes a history entry and
Back becomes unusable.
scroll: false, or the page jumps to the top on each result update.
The URL holds the query, so results are shareable and survive a refresh — module 4's rule.
This is a legitimate useEffect: synchronising React state with the URL, which
is outside React.
Refreshing after a mutation
revalidatePath("/cart"); // one route
revalidateTag("products"); // everything tagged
router.refresh(); // from a client component
Forgetting to revalidate is the most common bug in this area. The action succeeds, the database changes, and the page shows the old data — which looks exactly like the action failing.
When something "does not work" after a mutation, check the revalidate before anything else.
Preventing double submission
POST is not idempotent, from module 1, so a double tap creates two orders.
Three defences, and a real checkout wants all three:
Client: the isPending guard above.
Server action: check state before acting — a cart already converted to an order cannot be converted again.
API: an idempotency key for anything involving money. The client generates a key, sends it, and the server returns the original result for a repeat rather than acting twice. That is module 14.
Check your work
Where to fetch by default: the server. Client fetching is for responses to interaction.
Three loading thresholds: under 100ms show nothing, 100ms–1s a skeleton, over 1s a skeleton with some indication of progress.
What goes inside a Suspense boundary: the slow, secondary content. What
the user came for stays outside.
Why setOptimistic must be inside startTransition: the optimistic value
only exists for a transition's duration.
When not to be optimistic: payments, stock, anything irreversible, anything that often fails.
Why add-to-cart is not optimistic: it can genuinely fail on stock, and a reverted "added" is worse than a brief wait.
Why router.replace for search: push makes every keystroke a history
entry.
The most common bug after a mutation: forgetting to revalidate, which looks identical to the action failing.
Practice
- Add
loading.tsxwith a skeleton. Slow your API by a second to see it. - Wrap a slow section in
Suspenseand confirm the rest renders first. - Build the optimistic quantity stepper. Throttle to Slow 3G and confirm the number changes instantly.
- Make the action fail and confirm the value reverts.
- Move
setOptimisticoutsidestartTransitionand read the error. - Make add-to-cart optimistic, then force a stock failure. Describe how it feels.
- Build search with debouncing. Remove the debounce and count the requests for one word.
- Use
pushinstead ofreplaceand try the Back button after typing. - Mutate the cart without revalidating. Confirm the page looks broken, then add it.
- Double-click Add to cart with the guard removed. Confirm two items are added.
Next: failing without showing a blank screen.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship