Server State with React Query
Fetch, cache, and synchronize server data using TanStack Query v5. Covers pagination, search, background refetching, and loading states.
Principle
Server state is async, shared, and can become stale. TanStack Query owns the entire lifecycle — fetching, caching, deduplication, and background revalidation. Components declare what data they need via custom hooks and stay completely free of fetch logic.
Never mirror server data into useState. If it came from an API, it belongs in React Query's cache. Local state is only for UI — modals, toggles, input values.
Rules
- check_circleHooks own the fetchingQuery hooks go in hooks/queries/. Components only call the hook and render the result.
- check_circleHierarchical query keysStructure keys as arrays: ["users", "list", { search, page }] for granular cache invalidation.
- check_circleAlways set staleTimeDefault staleTime is 0 — every render refetches. Be explicit: 5 min for lists, 10 min for details.
- check_circleHandle all statesAlways render isLoading, isError, and empty states. Never assume data exists on first render.
Pattern
import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '@/lib/query-keys'; import { getUsers, type GetUsersParams } from '@/services/users'; export function useUsers(params: GetUsersParams = {}) { return useQuery({ queryKey: queryKeys.users.list(params), queryFn: () => getUsers(params), staleTime: 1000 * 60 * 5, // 5 minutes placeholderData: (prev) => prev, // no layout shift on page change }); }
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
In Next.js App Router, prefetch data in a Server Component and hydrate the client cache via HydrationBoundary. The user sees real data on first paint — no loading spinner.
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { getQueryClient } from '@/lib/get-query-client'; import { queryKeys } from '@/lib/query-keys'; import { getUsers } from '@/services/users'; export default async function UsersPage() { const qc = getQueryClient(); await qc.prefetchQuery({ queryKey: queryKeys.users.list({}), queryFn: () => getUsers({}), }); return ( <HydrationBoundary state={dehydrate(qc)}> <UserList /> </HydrationBoundary> ); }
Live Demo
Loading users...