deep-dive2025-01-25Β·13Β·218/348

TanStack Query Data Fetching Patterns and Optimization

Advanced TanStack Query techniques including query invalidation, optimistic updates, infinite queries, and proper cache configuration for production applications.

Introduction

TanStack Query (formerly React Query) has become the standard for server state management in React applications. Its declarative API for fetching, caching, and synchronizing server state eliminates the boilerplate of manual state management. However, misconfiguring cache times, ignoring stale-while-revalidate patterns, and improper query invalidation can lead to stale data displays and race conditions.

After implementing TanStack Query in several production applications, I have identified the patterns that prevent the most common issues. This post covers advanced cache configuration, optimistic updates, and the debugging techniques that make data fetching reliable.

Environment

node --version
# v20.11.0

npm list @tanstack/react-query
# @tanstack/react-query@5.17.0

npm list axios
# axios@1.6.5

The application structure:

app/
β”œβ”€β”€ hooks/
β”‚   β”œβ”€β”€ useUsers.ts
β”‚   β”œβ”€β”€ usePosts.ts
β”‚   └── useInfiniteScroll.ts
β”œβ”€β”€ api/
β”‚   β”œβ”€β”€ client.ts
β”‚   β”œβ”€β”€ users.ts
β”‚   └── posts.ts
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ UserList.tsx
β”‚   β”œβ”€β”€ PostEditor.tsx
β”‚   └── InfiniteFeed.tsx
└── providers/
    └── QueryProvider.tsx

Problem

The first issue was that stale data appeared on screen after mutations. When a user updated their profile, the list of users still showed the old name until the page was manually refreshed:

// Mutating the profile does not update the user list
const useUpdateProfile = () => {
  return useMutation({
    mutationFn: (data: UpdateProfileInput) => api.updateProfile(data),
    onSuccess: () => {
      // This does not automatically update the user list query
    },
  });
};

The second issue was that rapid API calls caused race conditions. When a user typed in a search box and pressed Enter quickly, the slower request sometimes returned after the faster one, displaying incorrect results.

The third issue was that infinite scroll queries were loading duplicate items. The fetchNextPage function was being called multiple times before the previous page finished loading, resulting in the same data being added to the list twice.

Analysis

TanStack Query uses a cache key system to identify queries. When a mutation completes, you must explicitly invalidate the affected queries using the same cache keys. Without invalidation, the cached data remains in memory and is served to components until it expires.

The race condition occurs because TanStack Query does not cancel previous requests by default. Each request resolves independently, and the last one to resolve wins regardless of the order the requests were initiated. The queryKey hash determines cache updates, not the request completion order.

The infinite scroll duplication issue is caused by the fetchNextPage function not checking whether a request is already in progress. Multiple calls before the first request completes result in duplicate page requests.

Solution

Configure global cache settings:

// providers/QueryProvider.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 5 * 60 * 1000, // 5 minutes
            gcTime: 10 * 60 * 1000, // 10 minutes (garbage collection)
            retry: 2,
            refetchOnWindowFocus: false,
            refetchOnReconnect: "always",
          },
          mutations: {
            retry: 1,
          },
        },
      })
  );

  return (
    
      {children}
    
  );
}

Invalidate queries after mutations:

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/users";

export function useUsers() {
  return useQuery({
    queryKey: ["users"],
    queryFn: () => api.getUsers(),
  });
}

export function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: UpdateUserInput) => api.updateUser(data),
    onSuccess: (updatedUser, variables) => {
      // Invalidate the user list
      queryClient.invalidateQueries({ queryKey: ["users"] });

      // Optimistically update the specific user in cache
      queryClient.setQueryData(["users", updatedUser.id], updatedUser);

      // Invalidate queries that depend on this user
      queryClient.invalidateQueries({
        queryKey: ["posts"],
        predicate: (query) => {
          return query.queryKey.includes(updatedUser.id);
        },
      });
    },
    onError: (error, variables, context) => {
      // Rollback optimistic update if provided
      if (context?.previousUsers) {
        queryClient.setQueryData(["users"], context.previousUsers);
      }
    },
  });
}

Implement optimistic updates:

// hooks/usePosts.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/posts";

export function useCreatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreatePostInput) => api.createPost(data),
    onMutate: async (newPost) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ["posts"] });

      // Snapshot the previous value
      const previousPosts = queryClient.getQueryData(["posts"]);

      // Optimistically update the cache
      queryClient.setQueryData(["posts"], (old: Post[] | undefined) => [
        { ...newPost, id: "temp-id", createdAt: new Date() },
        ...(old || []),
      ]);

      // Return context with snapshot for rollback
      return { previousPosts };
    },
    onError: (err, newPost, context) => {
      // Rollback on error
      if (context?.previousPosts) {
        queryClient.setQueryData(["posts"], context.previousPosts);
      }
    },
    onSettled: () => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });
}

Fix race conditions with abort controllers:

// hooks/useSearch.ts
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/search";

export function useSearch(query: string) {
  return useQuery({
    queryKey: ["search", query],
    queryFn: async ({ signal }) => {
      const response = await api.search(query, { signal });
      return response.data;
    },
    enabled: query.length >= 3,
    // Debounce the query by 300ms
    placeholderData: (previousData) => previousData,
  });
}

Implement infinite queries with proper deduplication:

// hooks/useInfiniteFeed.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { api } from "../api/posts";

export function useInfiniteFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  } = useInfiniteQuery({
    queryKey: ["posts", "feed"],
    queryFn: ({ pageParam = 1 }) => api.getFeedPage(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 1,
    // Prevent duplicate pages from being fetched
    maxPages: 10,
  });

  const posts = data?.pages.flatMap((page) => page.posts) ?? [];

  return {
    posts,
    fetchNextPage: () => {
      if (!isFetchingNextPage && hasNextPage) {
        fetchNextPage();
      }
    },
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  };
}

// components/InfiniteFeed.tsx
import { useEffect, useRef } from "react";
import { useInfiniteFeed } from "../hooks/useInfiniteFeed";

function InfiniteFeed() {
  const { posts, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteFeed();
  const observerRef = useRef();

  const lastPostRef = useRef(null);

  useEffect(() => {
    if (observerRef.current) observerRef.current.disconnect();

    observerRef.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    if (lastPostRef.current) {
      observerRef.current.observe(lastPostRef.current);
    }

    return () => observerRef.current?.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    
{posts.map((post, index) => (
))} {isFetchingNextPage &&
Loading more...
}
); }

Lessons Learned

  1. Always invalidate queries after mutations. The cache is a convenience, not a source of truth. After mutations, refetch to ensure consistency.

  2. Use onMutate for optimistic updates and always handle the rollback in onError. This provides instant feedback while maintaining data integrity.

  3. Configure staleTime based on your data freshness requirements. Financial data should have short stale times, while static content can use longer ones.

  4. Infinite queries need intersection observers with proper guards against duplicate fetch calls. The isFetchingNextPage flag is your safety check.

  5. Use placeholderData instead of keepPreviousData in TanStack Query v5 for smoother transitions between query states.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.