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

Limited TimeLearn More

react querytanstack queryserver statedata fetchingreact

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

Published

2026-06-23

Read Time

4 mins

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.