From global-plugin
Use when reviewing a change that writes data AND the UI caches or optimistically updates that data, or when cache/invalidation behaviour changes on either side. Do NOT use for pure DB write review (use `prisma-data-access-guard`) or pure UI state shape (use `frontend-implementation-guard`). Covers cache invalidation, optimistic updates, server/client divergence, stale reads.
npx claudepluginhub lgerard314/global-marketplace --plugin global-pluginThis skill is limited to using the following tools:
Prevent the class of bug where the database says one thing and a user's screen says another — stale caches, missed invalidations, optimistic updates that never reconcile, and server-rendered pages that silently serve day-old data. Apply this skill whenever a mutation, server action, or API route writes data that any TanStack Query cache, Next.js full-route cache, or optimistic UI layer also hol...
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Guides building MCP servers enabling LLMs to interact with external services via tools. Covers best practices, TypeScript/Node (MCP SDK), Python (FastMCP).
Share bugs, ideas, or general feedback.
Prevent the class of bug where the database says one thing and a user's screen says another — stale caches, missed invalidations, optimistic updates that never reconcile, and server-rendered pages that silently serve day-old data. Apply this skill whenever a mutation, server action, or API route writes data that any TanStack Query cache, Next.js full-route cache, or optimistic UI layer also holds. The goal is a single source of truth: the server's persisted state, reflected accurately and promptly to every connected client.
invalidateQueries() without a queryKey filter. — Why: blanket invalidation refetches every cached query in the application, produces unnecessary network load, and masks the real dependency graph so future regressions go undetected.onError rollback that restores the previous cache snapshot and an onSettled refetch that reconciles with server truth. — Why: without rollback, a server rejection leaves the UI permanently diverged from the database until a manual refresh.setQueryData when the server returns the full updated resource. — Why: trusting client-constructed state across a reload produces divergence when the server applies transforms (timestamps, computed fields, triggers) the client did not anticipate.revalidatePath or revalidateTag explicitly; no reliance on automatic or time-based revalidation to clear data written by the current action. — Why: fetch cache entries and full-route cache segments persist until explicitly purged; a mutation that does not call revalidate* will serve stale HTML to the next visitor for the duration of the cache TTL.BroadcastChannel or storage event when the application has concurrent-tab scenarios and local mutations must reflect in sibling tabs. — Why: TanStack Query's in-memory cache is process-scoped; a write in tab A is invisible to tab B's cache until that tab independently refetches.| Thought | Reality |
|---|---|
| "I'll just invalidate everything — simpler and safe." | Blind invalidation fires a refetch for every active query in the cache, spikes network and server load proportionally to the number of open queries, and makes dependency tracing impossible when bugs appear. |
| "Optimistic update is fine without rollback — the server rarely rejects." | When the server does reject (validation error, concurrent conflict, network timeout), the UI displays data that was never committed, and the divergence persists until the user navigates away or manually refreshes. |
| "Cache TTL handles freshness — users will get fresh data within a minute." | Users see stale data for exactly the wrong duration: the mutation succeeds, the UI still shows the old value, and the user re-submits or reports a bug before the TTL expires. |
invalidateQueries vs blind invalidationBad:
// Refires every active query in the entire app after a single order update
const mutation = useMutation({
mutationFn: updateOrder,
onSuccess: () => {
queryClient.invalidateQueries(); // no filter — scorched-earth refetch
},
});
Good:
const mutation = useMutation({
mutationFn: updateOrder,
onSuccess: (_data, variables) => {
// Only the queries that actually depend on this order
queryClient.invalidateQueries({
queryKey: ['orders', variables.orderId],
});
queryClient.invalidateQueries({
queryKey: ['orders', 'list'],
exact: false,
});
},
});
Bad:
const mutation = useMutation({
mutationFn: updateOrderStatus,
onMutate: async ({ orderId, status }) => {
// Sets optimistic state but provides no way to undo it on failure
queryClient.setQueryData(['orders', orderId], (old: Order) => ({
...old,
status,
}));
},
// No onError, no onSettled — UI diverges permanently on any server rejection
});
Good:
const mutation = useMutation({
mutationFn: updateOrderStatus,
onMutate: async ({ orderId, status }) => {
// Cancel in-flight refetches that would overwrite the optimistic value
await queryClient.cancelQueries({ queryKey: ['orders', orderId] });
// Snapshot current state so rollback is exact, not reconstructed
const previous = queryClient.getQueryData<Order>(['orders', orderId]);
queryClient.setQueryData(['orders', orderId], (old: Order) => ({
...old,
status,
}));
return { previous };
},
onError: (_err, { orderId }, context) => {
if (context?.previous) {
queryClient.setQueryData(['orders', orderId], context.previous);
}
},
onSettled: (_data, _err, { orderId }) => {
// Reconcile with server truth on both success and failure
queryClient.invalidateQueries({ queryKey: ['orders', orderId] });
},
});
revalidateTag vs implicit revalidationBad:
// Server action writes to the DB but relies on time-based cache expiry
export async function updateOrderAction(id: string, data: UpdateOrderInput) {
await db.order.update({ where: { id }, data });
// No revalidation — Next.js full-route cache serves stale HTML until TTL
}
Good:
'use server';
export async function updateOrderAction(id: string, data: UpdateOrderInput) {
await db.order.update({ where: { id }, data });
// Purge all cached responses tagged with this order's data
revalidateTag(`order:${id}`);
// Also purge list views that include this order
revalidateTag('orders:list');
}
// Tag the fetch that populates the cache:
const order = await fetch(`/api/orders/${id}`, {
next: { tags: [`order:${id}`, 'orders:list'] },
});
TanStack Query v5 builds a query graph keyed by queryKey arrays. Every invalidation is a cache-key operation: invalidateQueries marks matching keys as stale and immediately triggers background refetches for any query currently mounted by an active component. Scope invalidation to the dependency graph — wider wastes refetches, narrower leaves stale data.
Prefer hierarchical keys such as ['orders', orderId] for singletons and ['orders', 'list', { status, page }] for filtered collections. The exact: false option on invalidateQueries matches any key that begins with the supplied prefix, enabling a single call to purge all list variants after a mutation that touches a resource. The standard pattern after a write is: exact invalidation for the specific entity singleton, plus prefix-based invalidation for its parent list, in the same onSuccess callback.
When the mutation response includes the full updated resource, use setQueryData to populate the singleton cache entry immediately rather than waiting for a round-trip refetch. Prefer functional updater form for inferred typing. The list query can then be allowed to refetch in the background, giving the user instant feedback on the entity view while the list catches up.
For dependent queries — where query B's data is derived from or filtered by query A's result — ensure that invalidating A also invalidates B explicitly. invalidateQueries is imperative — call it for every key in the dependency chain within the same callback.
The three-callback pattern (onMutate, onError, onSettled) maps cleanly onto this contract. onMutate cancels competing in-flight refetches via cancelQueries — so a background refresh arriving after the mutation begins does not overwrite the optimistic state — then snapshots the current cache entry with getQueryData, writes the optimistic value with setQueryData, and returns the snapshot as context so onError and onSettled receive it.
onError receives the context returned from onMutate and restores the snapshot using setQueryData. Restore the snapshot, do not recompute. onSettled always calls invalidateQueries — on both success and failure — so the cache eventually reflects server truth regardless of outcome.
For list mutations (appending or removing items), splicing an item into or out of a paginated list cache is error-prone when cursor-based pagination is in use. In that case, prefer setQueryData only for the entity singleton and rely on invalidateQueries for the list queries rather than manually reconstructing cursor structure. The overhead of one list refetch is preferable to a subtle ordering or cursor-state bug in the optimistic list. React 19's useOptimistic is for render-level feedback only; use setQueryData for shared cache state.
Next.js 15 caches server component output in the full-route cache and fetch responses in the per-fetch data cache. A server action that mutates data without calling revalidatePath or revalidateTag leaves both caches populated with pre-mutation state. Time-based revalidation (next: { revalidate: 60 }) is appropriate for read-heavy pages where occasional staleness is acceptable, but it must never be the sole freshness mechanism for pages whose data changes via an explicit user-triggered mutation.
revalidateTag is the preferred mechanism because it is data-scoped rather than URL-scoped. Tag all fetch calls and unstable_cache wrappers that read a resource with a consistent naming scheme (e.g., next: { tags: ['order:${id}', 'orders:list'] }) and call revalidateTag from every server action or route handler that writes that resource. One tag flushes every URL embedding the resource. revalidatePath is appropriate when the resource is tightly coupled to a single canonical URL and maintaining a tag graph would add unnecessary complexity.
On-demand revalidation is available only from server-side code: server actions, route handlers, and API routes. In a hybrid architecture where mutations originate from a TanStack Query mutationFn calling a client-side fetch to a route handler, the route handler must call revalidateTag after a successful write. The TanStack Query onSuccess callback then calls invalidateQueries for client-side cache consistency. Both caches are independent — flush both.
A TanStack Query cache lives in memory within a single browser tab. A user with two tabs open who performs a mutation in tab A sees the result reflected immediately in that tab's cache, while tab B continues to display pre-mutation state until its next independent refetch. For most CRUD applications this is tolerable — TanStack Query's refetchOnWindowFocus default means tab B refetches on next focus. When the application has collaborative, financial, or multi-session semantics where tabs must converge within seconds without a focus event, explicit cross-tab messaging is required.
BroadcastChannel is the most direct mechanism: the tab that completes a mutation posts a typed message such as { type: 'invalidate', queryKey: ['orders', orderId] }, and every other tab listening on the same named channel calls queryClient.invalidateQueries with the received key. This produces a targeted, key-scoped background refetch in sibling tabs without a full-page reload. The BroadcastChannel listener should be registered once at application startup (e.g., in a top-level layout or a singleton module) and must be cleaned up on unmount.
WebSocket and SSE state introduces a reconnect-gap problem: messages emitted by the server during a client disconnection (background tab, brief network outage) are silently dropped. On reconnect, the application must treat the resume as a signal to reconcile rather than merely resume. The standard pattern is: on the socket's open or reconnect event, call queryClient.refetchQueries for every query key that the socket delivers updates for, optionally fetching a "catch-up since sequence N" endpoint first if the server supports it. Event handlers must be idempotent.
prisma-data-access-guard for the DB write itself; frontend-implementation-guard for where state lives in the component tree and context structure.resilience-and-error-handling's retry semantics (this skill focuses on cache correctness, not retry logic or circuit breaking).Produce a markdown report with these sections:
useMutation has a targeted invalidateQueries or setQueryData — list every mutation missing an invalidation step.onError rollback and onSettled refetch — list every onMutate without a matching onError.revalidatePath or revalidateTag explicitly.Required explicit scans:
useMutation call missing an onSuccess invalidation or setQueryData.onMutate callback without a corresponding onError that restores a cache snapshot.'use server' file) that calls a DB write without a subsequent revalidatePath or revalidateTag.invalidateQueries() call invoked without a queryKey argument.