React Query (TanStack Query) Deep Dive: Server State Management Patterns

Server state is fundamentally different from client state. It is asynchronous, shared across sessions, and can be stale the moment it is fetched. TanStack Query (formerly React Query) has become the de facto standard for managing server state in React because it treats these constraints as first-class concerns rather than edge cases. Here is how to use it beyond the basics.
Query Key Architecture
Query keys are TanStack Query's cache addressing system. A well-structured key hierarchy prevents cache collisions and enables targeted invalidation.
// Flat keys for simple resources
useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
// Hierarchical keys with parameters
useQuery({
queryKey: ['project', projectId, { includeTasks: true }],
queryFn: () => fetchProject(projectId, { includeTasks: true }),
})
// Nested keys for dependent queries
useQuery({
queryKey: ['project', projectId, 'tasks', filters],
queryFn: () => fetchTasks(projectId, filters),
enabled: !!projectId,
})
The rule of thumb: from most general to most specific. This lets you invalidate ['project'] to refetch all project-related data, or ['project', projectId] for a single project without touching its tasks.
Caching Strategies and Stale Times
TanStack Query gives you three configuration knobs: staleTime, gcTime (formerly cacheTime), and refetchInterval. Each serves a distinct purpose.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes before refetch
gcTime: 1000 * 60 * 30, // 30 minutes garbage collection
refetchOnWindowFocus: true,
retry: 2,
},
},
})
// Per-query overrides for critical data
function useUser() {
return useQuery({
queryKey: ['user'],
queryFn: fetchCurrentUser,
staleTime: 1000 * 60, // 1 minute - user data changes
gcTime: 1000 * 60 * 60, // 1 hour cache retention
})
}
// Real-time data with polling
function useLiveNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 1000 * 30, // Poll every 30 seconds
staleTime: 1000 * 10,
})
}
Optimistic Updates
Optimistic updates make your UI feel instant by applying mutations before the server confirms them. TanStack Query's onMutate callback handles this cleanly.
const mutation = useMutation({
mutationFn: updateTask,
onMutate: async (updatedTask) => {
// Cancel outgoing refetches to avoid overwriting
await queryClient.cancelQueries({ queryKey: ['tasks'] })
// Snapshot previous value for rollback
const previousTasks = queryClient.getQueryData(['tasks'])
// Optimistically update cache
queryClient.setQueryData(['tasks'], (old) =>
old.map((task) =>
task.id === updatedTask.id ? { ...task, ...updatedTask } : task
)
)
// Return context for rollback
return { previousTasks }
},
onError: (err, newTask, context) => {
// Rollback on failure
queryClient.setQueryData(['tasks'], context?.previousTasks)
},
onSettled: () => {
// Refetch to ensure server consistency
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
})
Infinite Queries and Pagination
TanStack Query provides useInfiniteQuery for cursor-based and offset-based pagination patterns.
function useInfiniteProjects() {
return useInfiniteQuery({
queryKey: ['projects', filters],
queryFn: ({ pageParam }) => fetchProjects({ ...filters, cursor: pageParam }),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? null,
})
}
function ProjectList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteProjects()
return (
<div>
{data?.pages.map((page) =>
page.items.map((project) => <ProjectCard key={project.id} project={project} />)
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
)
}
Mutation Side Effects and Cache Invalidation
Mutations are where server state actually changes. A common pattern is to invalidate related queries in the mutation's onSuccess handler to trigger refetches.
const createProject = useMutation({
mutationFn: (data) => api.post('/projects', data),
onSuccess: (newProject) => {
// Invalidate project list queries
queryClient.invalidateQueries({ queryKey: ['projects'] })
// Or directly update cache
queryClient.setQueryData(['project', newProject.id], newProject)
},
})
Query Deduplication and Request Waterfalls
TanStack Query automatically deduplicates concurrent requests for the same query key. This eliminates the waterfall problem where multiple components request the same data independently. When a parent component fetches a user and a child component requests the same user, TanStack Query serves the cached result without a second network request.
Building Reliable Data Layers
TanStack Query transforms server state management from manual cache juggling into declarative configuration. Combined with proper query key architecture, stale time policies, and optimistic updates, your React application becomes resilient to network failures and optimistic about user interactions.
At SoniNow, we implement TanStack Query patterns in production applications handling millions of daily queries. Our web development services cover data fetching architecture, cache strategy design, and performance optimization.
Stop fighting server state. Work with SoniNow to build data layers that scale.
Related Insights

Building Accessible React Applications: WCAG 2.2 Compliance Guide
A guide to building WCAG 2.2 compliant React applications including semantic HTML, ARIA attributes, keyboard navigation, focus management, and automated accessibility testing.

Code Splitting and Lazy Loading in React: Performance Optimization Guide
A comprehensive guide to code splitting and lazy loading in React applications including React.lazy, Suspense, route-based splitting, and component-level chunking.

Building a Design System with React, TypeScript, and Storybook
A complete guide to building a scalable design system using React, TypeScript, and Storybook including component architecture, theming, accessibility, and documentation.