TanStack Query (React Query): Server State Management Done Right
Most React applications mix two fundamentally different kinds of state: client state (UI toggles, form inputs, selected items) and server state (data fetched from an API). Redux and useState handle client state well, but server state has unique challenges β caching, background refetching, stale data, loading states β that general state libraries handle poorly. TanStack Query (formerly React Query) solves these problems elegantly.
The Problem with Manual Data Fetching
Without TanStack Query, fetching data looks like this:
javascriptfunction UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); fetch(`/api/users/${userId}`) .then(r => r.json()) .then(data => { if (!cancelled) { setUser(data); setLoading(false); } }) .catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } }); return () => { cancelled = true; }; }, [userId]); if (loading) return <Spinner />; if (error) return <Error message={error} />; return <div>{user.name}</div>; }
Every component repeats this pattern. There is no caching β navigating away and back re-fetches. There is no background refresh. Multiple components fetching the same resource make duplicate requests.
Setup
bashnpm install @tanstack/react-query @tanstack/react-query-devtools
jsximport { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, -- data fresh for 5 minutes retry: 2, -- retry failed requests twice refetchOnWindowFocus: true, -- refetch when tab regains focus }, }, }); function App() { return ( <QueryClientProvider client={queryClient}> <Router /> <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> ); }
useQuery: Fetching Data
javascriptimport { useQuery } from "@tanstack/react-query"; async function fetchUser(userId) { const response = await fetch(`/api/users/${userId}`); if (!response.ok) throw new Error("Failed to fetch user"); return response.json(); } function UserProfile({ userId }) { const { data: user, isLoading, isError, error, isFetching, -- true even on background refetches isStale, -- true when data is older than staleTime refetch, -- manually trigger a refetch } = useQuery({ queryKey: ["users", userId], -- unique cache key queryFn: () => fetchUser(userId), staleTime: 60_000, -- override default: fresh for 1 minute enabled: !!userId, -- only run when userId is truthy }); if (isLoading) return <Spinner />; if (isError) return <Error message={error.message} />; return ( <div> {isFetching && <small>Refreshing...</small>} <h1>{user.name}</h1> </div> ); }
The query key is the cache key. ["users", 1] and ["users", 2] are separate cache entries. When userId changes, TanStack Query automatically fetches the new data.
useQuery with Dependent Queries
javascriptfunction UserPosts({ userId }) { -- First fetch the user const { data: user } = useQuery({ queryKey: ["users", userId], queryFn: () => fetchUser(userId), }); -- Then fetch their posts (only when user is available) const { data: posts } = useQuery({ queryKey: ["posts", { authorId: user?.id }], queryFn: () => fetchPosts({ authorId: user.id }), enabled: !!user, -- waits for user to be loaded }); return <PostList posts={posts} />; }
Parallel Queries
javascriptfunction Dashboard({ userId }) { -- Both queries run simultaneously const userQuery = useQuery({ queryKey: ["users", userId], queryFn: () => fetchUser(userId) }); const postsQuery = useQuery({ queryKey: ["posts", userId], queryFn: () => fetchUserPosts(userId) }); const statsQuery = useQuery({ queryKey: ["stats", userId], queryFn: () => fetchStats(userId) }); if (userQuery.isLoading || postsQuery.isLoading) return <Spinner />; return ( <div> <UserCard user={userQuery.data} /> <PostList posts={postsQuery.data} /> {statsQuery.data && <StatsChart data={statsQuery.data} />} </div> ); }
useMutation: Creating, Updating, Deleting
javascriptimport { useMutation, useQueryClient } from "@tanstack/react-query"; function CreatePostForm() { const queryClient = useQueryClient(); const createPost = useMutation({ mutationFn: async (newPost) => { const response = await fetch("/api/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(newPost), }); if (!response.ok) throw new Error("Failed to create post"); return response.json(); }, -- Invalidate the posts list so it refetches onSuccess: (createdPost) => { queryClient.invalidateQueries({ queryKey: ["posts"] }); -- Or add the new post directly to cache queryClient.setQueryData(["posts", createdPost.id], createdPost); }, onError: (error) => { toast.error(`Failed to create post: ${error.message}`); }, }); return ( <form onSubmit={e => { e.preventDefault(); createPost.mutate({ title: e.target.title.value, content: e.target.content.value }); }}> <input name="title" /> <textarea name="content" /> <button type="submit" disabled={createPost.isPending}> {createPost.isPending ? "Creating..." : "Create Post"} </button> {createPost.isError && <p className="error">{createPost.error.message}</p>} </form> ); }
Optimistic Updates
Update the UI immediately before the server confirms, then reconcile:
javascriptconst toggleLike = useMutation({ mutationFn: ({ postId, liked }) => fetch(`/api/posts/${postId}/like`, { method: liked ? "POST" : "DELETE", }), onMutate: async ({ postId, liked }) => { -- Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: ["posts", postId] }); -- Snapshot the previous value const previousPost = queryClient.getQueryData(["posts", postId]); -- Optimistically update the cache queryClient.setQueryData(["posts", postId], old => ({ ...old, liked, likeCount: liked ? old.likeCount + 1 : old.likeCount - 1, })); -- Return context with snapshot for rollback return { previousPost }; }, onError: (err, variables, context) => { -- Rollback on error queryClient.setQueryData(["posts", variables.postId], context.previousPost); }, onSettled: (data, error, variables) => { -- Always refetch to sync with server queryClient.invalidateQueries({ queryKey: ["posts", variables.postId] }); }, });
Infinite Queries (Pagination / Load More)
javascriptimport { useInfiniteQuery } from "@tanstack/react-query"; function PostFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ queryKey: ["posts"], queryFn: ({ pageParam = 1 }) => fetch(`/api/posts?page=${pageParam}&limit=20`).then(r => r.json()), getNextPageParam: (lastPage, allPages) => { return lastPage.hasMore ? allPages.length + 1 : undefined; }, initialPageParam: 1, }); const posts = data?.pages.flatMap(page => page.posts) ?? []; return ( <div> {posts.map(post => <PostCard key={post.id} post={post} />)} {hasNextPage && ( <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage} > {isFetchingNextPage ? "Loading..." : "Load More"} </button> )} </div> ); }
Prefetching
Prefetch data before the user navigates to improve perceived performance:
javascriptconst queryClient = useQueryClient(); -- Prefetch on hover (before user clicks) function PostLink({ post }) { return ( <Link to={`/posts/${post.id}`} onMouseEnter={() => { queryClient.prefetchQuery({ queryKey: ["posts", post.id], queryFn: () => fetchPost(post.id), staleTime: 10_000, }); }} > {post.title} </Link> ); } -- Prefetch on the server (Next.js) export async function getServerSideProps() { const queryClient = new QueryClient(); await queryClient.prefetchQuery({ queryKey: ["posts"], queryFn: fetchPosts, }); return { props: { dehydratedState: dehydrate(queryClient) }, }; }
Common Interview Questions
Q: What is the difference between staleTime and cacheTime (gcTime)?
staleTime controls how long data is considered fresh β during this period, no background refetch occurs and the cached data is returned immediately. After staleTime, data is considered stale and will be refetched in the background on the next mount or focus. gcTime (formerly cacheTime) controls how long inactive query data stays in memory before being garbage collected. Default: staleTime=0 (always stale), gcTime=5 minutes.
Q: How does query invalidation work?
queryClient.invalidateQueries marks matching queries as stale and triggers a background refetch if they are currently mounted. You pass a query key filter β { queryKey: ["posts"] } invalidates all queries whose key starts with "posts". This is the standard pattern after a mutation: invalidate related queries so they automatically refresh.
Q: When would you use TanStack Query instead of Redux for server data?
TanStack Query is purpose-built for server state β async fetching, caching, deduplication, background refresh, and pagination. Redux requires significant boilerplate (actions, reducers, thunks) to handle these patterns. Use TanStack Query for all API data; use Redux or Zustand for pure client state that does not come from a server. Many teams use both: TanStack Query for data fetching, Zustand for UI state.
Practice on Froquiz
Data fetching patterns and state management are key React interview topics. Test your React knowledge on Froquiz β covering hooks, rendering, performance, and patterns.
Summary
- TanStack Query manages server state β caching, deduplication, background refresh, loading states
queryKeyis the cache key β array format enables precise cache controlstaleTimecontrols freshness;gcTimecontrols memory retentionuseMutationhandles writes;onSuccessinvalidates related queries to trigger refetch- Optimistic updates: update cache immediately, roll back on error, always refetch on settle
useInfiniteQueryhandles paginated and "load more" patterns cleanly- Prefetch on hover for instant navigation β data is ready before the user clicks