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.5The 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.tsxProblem
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
Always invalidate queries after mutations. The cache is a convenience, not a source of truth. After mutations, refetch to ensure consistency.
Use
onMutatefor optimistic updates and always handle the rollback inonError. This provides instant feedback while maintaining data integrity.Configure
staleTimebased on your data freshness requirements. Financial data should have short stale times, while static content can use longer ones.Infinite queries need intersection observers with proper guards against duplicate fetch calls. The
isFetchingNextPageflag is your safety check.Use
placeholderDatainstead ofkeepPreviousDatain TanStack Query v5 for smoother transitions between query states.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.