React Query v6: Server State Data Fetching Guide
React Query v6 has arrived as a game‑changer for handling server‑state in React applications. It abstracts away the boilerplate of data fetching, caching, and synchronisation, letting you focus on UI logic instead of network quirks. In this guide we’ll walk through the core concepts, set up a fresh project, and dive into three real‑world examples that showcase the power of the new API.
Getting Started with React Query v6
First, install the library and its peer dependencies. React Query v6 drops support for React <15 and requires React ≥ 16.8 for hooks.
npm install @tanstack/react-query
# or with yarn
yarn add @tanstack/react-query
Next, wrap your app with the QueryClientProvider. This creates a singleton QueryClient that holds caches, defaults, and global listeners.
import React from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
That’s it—your component tree now has access to React Query’s powerful hooks.
Fundamental Hooks: useQuery and useMutation
The useQuery hook fetches read‑only data. It accepts a unique query key and an async function that returns the payload.
import { useQuery } from "@tanstack/react-query";
function usePosts() {
return useQuery({
queryKey: ["posts"],
queryFn: async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!res.ok) throw new Error("Network error");
return res.json();
},
});
}
React Query automatically caches the result under the ["posts"] key, marks it fresh for staleTime, and refetches according to the configured policies.
When you need to modify server data, useMutation steps in. It gives you a mutate function, status flags, and callbacks for side‑effects.
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost) => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error("Failed to create");
return res.json();
},
// Optimistic update: prepend the new post to the cached list
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ["posts"] });
const previous = queryClient.getQueryData(["posts"]);
queryClient.setQueryData(["posts"], (old) => [newPost, ...old]);
return { previous };
},
// Rollback on error
onError: (err, newPost, context) => {
queryClient.setQueryData(["posts"], context.previous);
},
// Refetch after success to sync with server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
}
Notice how the mutation hooks into the cache, providing a snappy UI while keeping the server in sync.
Practical Example #1: Infinite Scrolling with useInfiniteQuery
Infinite scrolling is a common pattern for feeds, product catalogs, and dashboards. React Query v6 introduces useInfiniteQuery, which handles page parameters, concatenation, and loading states out of the box.
import { useInfiniteQuery } from "@tanstack/react-query";
function usePaginatedPosts() {
return useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: async ({ pageParam = 1 }) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
);
if (!res.ok) throw new Error("Failed to fetch page");
return res.json();
},
getNextPageParam: (lastPage, allPages) => {
// JSONPlaceholder returns 100 posts total; stop after page 10
const nextPage = allPages.length + 1;
return nextPage <= 10 ? nextPage : undefined;
},
});
}
In your component you can map over data.pages and call fetchNextPage when the user scrolls near the bottom.
import { useEffect, useRef } from "react";
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
error,
} = usePaginatedPosts();
const loaderRef = useRef();
// IntersectionObserver to trigger next page
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
});
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [loaderRef, hasNextPage, fetchNextPage]);
if (status === "loading") return <p>Loading posts…</p>;
if (status === "error") return <p>Error: {error.message}</p>;
return (
<div>
{data.pages.map((page, i) => (
<ul key={i}>
{page.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
))}
<div ref={loaderRef}>
{isFetchingNextPage
? <p>Loading more…</p>
: hasNextPage
? <p>Scroll to load more</p>
: <p>No more posts</p>}
</div>
</div>
);
}
Pro tip: CombineuseInfiniteQuerywithkeepPreviousData: trueif you want a seamless transition when the user changes filters or sorts.
Practical Example #2: Dependent Queries for Detail Views
Often you need to fetch a list first, then fetch details for a selected item. Dependent queries let you express this relationship declaratively.
import { useQuery } from "@tanstack/react-query";
function useUser(userId) {
return useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!res.ok) throw new Error("User not found");
return res.json();
},
// The query runs only when userId is truthy
enabled: !!userId,
});
}
In a master‑detail UI you can combine the list query with the dependent detail query. The UI will show a loading spinner for the detail only after a user is selected.
function UserDetail({ selectedUserId }) {
const { data: user, isLoading, isError, error } = useUser(selectedUserId);
if (!selectedUserId) return <p>Select a user to see details.</p>;
if (isLoading) return <p>Loading user…</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
<p>Company: {user.company.name}</p>
</div>
);
}
Practical Example #3: Prefetching & Background Refresh
Prefetching improves perceived performance by loading data before the user navigates to a new route. React Query’s queryClient.prefetchQuery does the heavy lifting.
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
function NavLink({ to, label, prefetchKey, prefetchFn }) {
const queryClient = useQueryClient();
// Prefetch when the link enters the viewport (or on hover)
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: prefetchKey,
queryFn: prefetchFn,
staleTime: 2 * 60 * 1000, // keep fresh for 2 minutes
});
};
return (
<a href={to} onMouseEnter={handleMouseEnter}>{label}</a>
);
}
When the user clicks the link, the data is already cached, resulting in an instant render. Pair this with refetchOnMount: false to avoid redundant network calls.
Pro tip: Use useIsFetching to display a global loading indicator whenever any query is in flight, giving users consistent feedback across the app.
Advanced Patterns: Stale‑While‑Revalidate & Selectors
React Query v6 embraces the “stale‑while‑revalidate” (SWR) paradigm by default. Data is served from cache instantly, then refreshed in the background if it’s stale. You can fine‑tune this with staleTime and refetchInterval.
const weatherQuery = useQuery({
queryKey: ["weather", location],
queryFn: fetchWeather,
staleTime: 10 * 60 * 1000, // 10 minutes
refetchInterval: 5 * 60 * 1000, // poll every 5 minutes
});
Selectors let you transform cached data without re‑rendering the entire component tree. The select option runs a memoised function on the raw result.
const { data: userNames } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
select: (data) => data.map((u) => u.name), // returns only names
});
Because the selector is memoised, components that only need the derived slice won’t re‑render when unrelated fields change.
Error Handling & Retry Strategies
Network instability is inevitable. React Query provides built‑in retries, exponential back‑off, and error boundaries. You can customise the retry logic per query or globally.
const { data, error, isError } = useQuery({
queryKey: ["profile"],
queryFn: fetchProfile,
retry: (failureCount, error) => {
// Retry only on 5xx errors, up to 3 attempts
if (error.status >= 500 && failureCount < 3) return true;
return false;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // exponential back‑off
});
If a query exhausts its retries, you can surface a friendly UI and optionally provide a manual “Retry” button that calls refetch().
Testing React Query Logic
Testing is smoother because the cache is deterministic. Wrap your component with a custom QueryClientProvider that uses a fresh client per test, then assert on the UI states.
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Posts from "./Posts";
function renderWithClient(ui) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
);
}
test("shows posts after fetch", async () => {
renderWithClient(<Posts />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => expect(screen.getByText(/post title/i)).toBeInTheDocument());
});
Disabling retries in tests prevents flaky timeouts and makes the outcome predictable.
Performance Optimisations
React Query is already lightweight, but a few knobs can squeeze out extra speed:
- Cache Size: Set
maxCacheSizeon the client to evict least‑recently‑used queries. - Structural Sharing: The library uses
immer-like shallow comparison; avoid mutating objects returned fromqueryFn. - Batching: Wrap multiple
setQueryDatacalls in a singlequeryClient.setQueriesDatato trigger only one re‑render.
Migration Checklist from v5 to v6
Most v5 code works unchanged, but there are a few breaking API shifts:
- All hooks now live under
@tanstack/react-queryinstead ofreact-query. - The
queryKeymust be an array; primitive keys are deprecated. useQueryClientno longer returns the client directly; you must call methods on the returned object.- Option names have been normalised:
onSuccess,onError,onSettledremain, butonSuccessnow receives the data as the first argument.
Run the official migration script (npx @tanstack/react-query@latest migrate) to get a report of required changes.
Conclusion
React Query v6 streamlines server‑state management with a declarative, cache‑first approach. By mastering useQuery, useInfiniteQuery, and useMutation, you can build responsive UIs that feel instant, even on flaky networks. Leverage prefetching, dependent queries, and advanced retry strategies to create robust, production‑ready data layers. With the patterns and tips outlined above, you’re ready to adopt React Query v6 across any React codebase and reap the benefits of less boilerplate, better performance, and happier developers.