Topics
v5.0.0
On this page · sections

Server State & Data Fetching

TanStack Query v5 separates freshness (staleTime) from memory retention (gcTime). A trading dashboard combines websocket tick streams, throttled UI updates, and React Query for REST — layered by update frequency, not one state tree.

Knob / LayerControls
staleTimeHow long data is “fresh” — no auto-refetch while fresh
gcTimeHow long inactive query data stays in memory after last observer unmounts
Market tape100+/sec ingest, 4–10/sec display — Zustand normalized store + selectors
Positions / ordersSeconds to minutes — React Query REST + event invalidation
Order entryOn submit — optimistic mutation + rollback

staleTime vs gcTime

Definition

'use client'; function Product({ id }: { id: string }) { const { data, isFetching } = useQuery({ queryKey: ['product', id], queryFn: () => fetchProduct(id), staleTime: 60_000, // fresh 1 min — no refetch on remount within minute gcTime: 10 * 60_000, // keep cache 10 min after unmount }); return <div>{data?.name} {isFetching && '(updating…)'}</div>; }
Fetch success → data fresh for staleTime → staleTime elapses → stale (still in cache) → refetch triggers may run in background → all observers unmount → gcTime countdown → gcTime elapses → garbage collected Defaults (v5) → staleTime: 0 · gcTime: 5 minutes

isFetching = network in flight. isStale = past staleTime — may still display cached data during background refetch. staleTime: Infinity → manual invalidation only.

Interview: v5 renamed cacheTime → gcTime to clarify garbage collection of inactive queries, not freshness.

Refetch triggers

Definition

Refetch when query is stale AND a trigger fires:

mount · window focus · network reconnect · interval · manual refetch()
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: false, retry: 2, }, }, }); function Orders() { const { data, isFetching } = useQuery({ queryKey: ['orders'], queryFn: fetchOrders, refetchInterval: 60_000, refetchIntervalInBackground: false, }); return <section aria-busy={isFetching}>{/* ... */}</section>; }

Background refetch: show subtle isFetching indicator — not full skeleton. Pagination: placeholderData: (prev) => prev keeps previous page visible while next loads.

Invalidation and key factories

Definition

const orderKeys = { all: ['orders'] as const, detail: (id: string) => [...orderKeys.all, id] as const, }; function useCancelOrder() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => cancelOrder(id), onSuccess: (_data, id) => { qc.invalidateQueries({ queryKey: orderKeys.all }); qc.invalidateQueries({ queryKey: orderKeys.detail(id) }); }, }); }
['orders'] → list ['orders', 'abc'] → detail ['orders', 'abc', 'lines'] → nested invalidateQueries({ queryKey: ['orders'] }) → all three become stale

Optimistic flow: onMutate snapshot → setQueryData patch → onError restore → onSettled invalidate. App Router: Server Action calls revalidateTag('orders') for RSC; client calls invalidateQueries — both layers must align.

Interview: setQueryData when you know response shape; invalidate when side effects are unpredictable.

SSR dehydrate/hydrate

Definition

// app/users/page.tsx export default async function UsersPage() { const qc = new QueryClient(); await qc.prefetchQuery({ queryKey: ['users'], queryFn: fetchUsersServer }); return ( <HydrationBoundary state={dehydrate(qc)}> <UsersClient /> </HydrationBoundary> ); }
'use client'; export function UsersClient() { const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsersClient, staleTime: 60_000, // match server freshness — avoid immediate refetch }); // ... }
1. Server creates per-request QueryClient (never global — PII leak) 2. prefetchQuery populates cache 3. dehydrate → HydrationBoundary state 4. Client rehydrates — useQuery skips fetch if still fresh

staleTime: 0 after SSR prefetch → immediate client refetch — wasteful double fetch.

Cache policy by entity volatility

Definition

Category listing staleTime 5–15 min · gcTime 30 min · no focus refetch PDP detail staleTime 1–2 min · tag invalidation on CMS publish Inventory staleTime 0–30s · focus refetch or websocket Cart staleTime 0 · optimistic mutations · invalidate every action Search staleTime 0 · short gcTime · debounced query keys
export const cachePolicy = { catalog: { staleTime: 10 * 60_000, gcTime: 30 * 60_000 }, product: (id: string) => ({ queryKey: ['product', id] as const, staleTime: 90_000, gcTime: 15 * 60_000, }), inventory: (sku: string) => ({ queryKey: ['inventory', sku] as const, staleTime: 15_000, refetchOnWindowFocus: true, }), cart: { staleTime: 0, gcTime: 5 * 60_000 }, };

CMS webhook must fire both:

revalidateTag(`product-${id}`) → Next Data Cache invalidateQueries(['product', id]) → React Query client

Interview: Volatility drives staleTime, not one global default. Document dual-cache story in an ADR.

Socket to pixels pipeline

Definition

WebSocket → parse frames → normalized map symbol → quote → store.applyTick → per-symbol selector → only visible rows re-render
'use client'; import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; type Quote = { symbol: string; price: number; ts: number }; const useMarket = create<{ quotes: Record<string, Quote>; applyTick: (tick: Quote) => void }>()( subscribeWithSelector((set) => ({ quotes: {}, applyTick: (tick) => set((s) => ({ quotes: { ...s.quotes, [tick.symbol]: tick } })), })), ); function PriceCell({ symbol }: { symbol: string }) { const price = useMarket((s) => s.quotes[symbol]?.price); return <td>{price ?? '—'}</td>; }
WS message → O(1) patch one symbol Parent setState → O(n) re-render all rows — forbidden Row selector → O(1) re-render one cell

Never setQuotes([...500 rows]) each tick. React Query is wrong for tick stream — use store + throttle for ticks, Query for REST entities.

Interview: Draw the pipeline and name fan-out cost explicitly.

Throttle display, not ingest

Definition

Ingest all ticks for audit/compliance. Throttle render flush — latest wins per symbol. Use startTransition for batched store updates. Config per asset class: equities 100ms, illiquid alts 500ms.

Throttle socket receive → wrong; lose audit data Throttle render flush → correct; traders see latest price path

500-symbol dashboard architecture

Real-world example

function Dashboard() { useMarketSocket(process.env.NEXT_PUBLIC_WS_URL!); const { data: positions } = useQuery({ queryKey: ['positions'], queryFn: fetchPositions, refetchInterval: 30_000, }); const selected = useUiStore((s) => s.selectedSymbol); return ( <div className="grid"> <VirtualTape symbols={WATCHLIST_500} /> <OrderTicket symbol={selected} /> <PositionsPanel rows={positions ?? []} /> </div> ); }
Reject → per-tick setState on parent; Query polling every 100ms for prices Reject → one React state tree for tape + positions + news Reject → Date.now() for compliance timestamps — use server ts Workers → option greeks off main thread; postMessage into store Chaos → kill WS; REST snapshot recovery + degraded mode banner

Display server ts from tick — never client-generated timestamps for compliance UI.

Senior Interview Summary

What to say

staleTime controls freshness — no auto-refetch while fresh. gcTime controls inactive memory retention after unmount. Market ticks flow through a normalized Zustand store with per-row selectors and a 100ms display throttle. Positions use React Query on REST with event-driven invalidation. SSR needs per-request QueryClient with staleTime matched to RSC revalidate.

Cache checklist

staleTime → freshness window; stale ≠ deleted gcTime → inactive memory; was cacheTime in v4 Key factories → hierarchical prefix invalidation SSR → per-request QueryClient; HydrationBoundary Dual cache → revalidateTag + invalidateQueries on webhook WS pipeline → normalize → per-symbol selector → throttle display

Best mental model

staleTime → "how long before I consider refetching" gcTime → "how long inactive data stays in memory" Ingest → "all ticks logged" Display → "latest wins per symbol at 4–10/sec"

Practical rule

Catalog → long staleTime, no focus refetch Inventory → short staleTime or websocket Cart → staleTime 0, optimistic + invalidate on every action Never → parent setState per tick with 500 symbols Chaos test → kill WS; verify REST fallback + degraded banner

Last updated on