In an e commerce product listing where multiple users add to cart, how would you use React Query or SWR with optimistic updates to prevent stale UI?
Optimistic update on click: instantly increment the cart count in the React Query cache, then fire the mutation. On error, rollback. Use `useMutation` with `onMutate` (snapshot + optimistic write), `onError` (restore snapshot), `onSettled` (invalidate to refetch source of truth). For multi-user race conditions, server returns the canonical state and we reconcile. Show a 'syncing' indicator if the mutation is in flight.
Classic React Query optimistic update pattern with rollback.
The setup
import { useMutation, useQueryClient } from '@tanstack/react-query';
function useAddToCart() {
const qc = useQueryClient();
return useMutation({
mutationFn: (item: CartItem) =>
fetch('/api/cart', { method: 'POST', body: JSON.stringify(item) })
.then(r => { if (!r.ok) throw new Error('failed'); return r.json(); }),
// 1. Optimistic write
onMutate: async (item) => {
// cancel in-flight refetches to avoid overwriting our optimistic write
await qc.cancelQueries({ queryKey: ['cart'] });
// snapshot current state
const previous = qc.getQueryData<Cart>(['cart']);
// apply optimistic update
qc.setQueryData<Cart>(['cart'], old => ({
...old!,
items: [...(old?.items ?? []), item],
totalCount: (old?.totalCount ?? 0) + 1,
}));
// return context for rollback
return { previous };
},
// 2. Rollback on error
onError: (_err, _item, ctx) => {
if (ctx?.previous) qc.setQueryData(['cart'], ctx.previous);
toast.error('Failed to add — please try again');
},
// 3. Always re-sync after settle
onSettled: () => {
qc.invalidateQueries({ queryKey: ['cart'] });
},
});
}Component
function AddToCart({ product }: { product: Product }) {
const mutate = useAddToCart();
return (
<button
onClick={() => mutate.mutate({ productId: product.id, qty: 1 })}
disabled={mutate.isPending}
>
{mutate.isPending ? 'Adding…' : 'Add to cart'}
</button>
);
}What happens
- User clicks → cart count UI increments instantly (optimistic).
- Network request fires in background.
- If success → server confirms;
onSettledinvalidates → background refetch syncs the canonical state. - If failure →
onErrorrolls back the cache; user sees a toast.
UI never blocks on the network.
Race conditions across users
Two users adding the same item: both clients see the optimistic increment. After the round-trip:
- Server stores both adds (cart per-user — no conflict).
- If inventory is shared: server may reject one with 'out of stock'.
onErrorrolls back that client.
For shared resources (limited inventory, concurrent edits), the server is authoritative. The client always reconciles to server state via the onSettled invalidation.
Stale UI prevention
cancelQueriesin onMutate: prevents a refetch from overwriting our optimistic write.onSettledinvalidates: triggers a refetch so the canonical state replaces the optimistic.staleTimeon the cart query controls how often background refetches happen.
Mutation queue (offline-friendly)
For mobile/PWA cases, queue mutations when offline and replay them:
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
retry: 3,
networkMode: 'offlineFirst',
},
},
});React Query persists mutations and retries when the network returns.
WebSocket sync (real-time multi-user)
For 'multiple users see the same cart' (e.g. shared carts), pair the optimistic update with a WebSocket subscription:
useEffect(() => {
const sock = new WebSocket(...);
sock.onmessage = (e) => {
const update = JSON.parse(e.data);
qc.setQueryData(['cart'], update);
};
return () => sock.close();
}, []);Updates from other users land in the cache in near-real-time.
UX considerations
- Latency feel: 0ms because optimistic.
- Error UX: toast + rollback. Don't just silently fail.
- Double-click protection: disable the button while
isPending, or queue clicks. - Inventory feedback: 'only 3 left' messaging needs server-side check; show after success.
What interviewers look for
- Knowing
onMutate/onError/onSettledpattern. cancelQueriesbefore optimistic write.- Snapshot + restore for rollback.
- Invalidate at the end to reconcile.
- Mention WebSockets for true multi-user sync.
- Discuss inventory / race conditions at the server boundary.
Senior framing
Optimistic UI is a UX win but requires discipline: rollback on error, invalidate at the end, and accept that the server is the source of truth. The client's job is to make latency invisible without ever lying about final state.
Follow-up questions
- •Why call cancelQueries before optimistic write?
- •How would you handle inventory limits being exceeded?
- •When would you use WebSockets vs polling for cart sync?
Common mistakes
- •Forgetting to rollback on error — UI shows phantom items.
- •Skipping cancelQueries — concurrent refetch overwrites optimistic state.
- •Not invalidating after settle — UI drifts from server.
Performance considerations
- •Optimistic updates make the UI feel instant regardless of network. The cost is the rollback path on error. For high-failure-rate operations, the optimistic feel is worse than a deliberate spinner.
Edge cases
- •User clicks rapidly — multiple in-flight mutations; queue or debounce.
- •Network flap — retry on transient errors, give up on validation errors.
- •Inventory race — server says 'out of stock'; UI rolls back specific item, not whole cart.
Real-world examples
- •Amazon, Shopify, every modern ecommerce. Twitter's 'like' button is optimistic. Linear's drag-and-drop is optimistic. The pattern is standard for any 'feels instant' UI.