What is React Query and Why You Should Use It?
You've written the "useEffect". You've added the "useState" for loading, error, and data. You've handled the cleanup. You've done it a hundred times. There's a better way.
Loading articles...
You've written the "useEffect". You've added the "useState" for loading, error, and data. You've handled the cleanup. You've done it a hundred times. There's a better way.

React is extraordinarily good at one thing: rendering UI as a function of state. Give it state, it gives you a screen. Change the state, the screen updates. Clean, predictable, composable.
What React does not give you is any opinion on where that state comes from.
For local, ephemeral UI state — whether a modal is open, which tab is selected, what the user typed in a field — useState and useReducer are perfect. But the moment you need to fetch data from a server, the situation gets complicated fast.
Consider what a "simple" data-fetching pattern in vanilla React actually requires:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
setUser(data);
setIsLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]);
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <UserCard user={user!} />;
}
And this is still the happy path. We haven't handled:
This is server state management — and it's a fundamentally different problem from UI state management. React Query (officially known as TanStack Query) exists to solve exactly this.
React Query is an asynchronous state management library for React (with adapters for Vue, Solid, Svelte, and Angular via TanStack Query). It provides a set of hooks and utilities that handle the full lifecycle of server-side data in your application.
At its core, React Query introduces two primitives:
But the real value isn't in those primitives themselves. It's in everything React Query automatically manages around them: a smart, normalized cache with configurable staleness, background synchronization, request deduplication, retry strategies, garbage collection, and developer tooling.
Installed by more than 10 million projects per week on npm, React Query has become the de facto standard for server state in React applications.
npm install @tanstack/react-query
# Optional but highly recommended
npm install @tanstack/react-query-devtools
Wrap your application with a QueryClientProvider:
// app/layout.tsx (Next.js App Router)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient();
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</body>
</html>
);
}
Note for Next.js App Router users: since
QueryClientProvideris a client component, you'll want to extract it into a'use client'wrapper component. The TanStack Query docs cover this pattern in detail.
useQuery HookThe useQuery hook is your primary tool for fetching and caching data. Let's rewrite the earlier example:
import { useQuery } from '@tanstack/react-query';
async function fetchUser(userId: string): Promise<User> {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spinner />;
if (isError) return <ErrorMessage error={error} />;
return <UserCard user={user!} />;
}
Same behavior, a fraction of the code. But more importantly: you're now getting all the features you weren't handling before — caching, deduplication, background refetching — for free.
queryKeyThe queryKey is the most important concept in React Query. It's an array that uniquely identifies a query. React Query uses it to:
Keys are hierarchical and serializable:
// A list of all users
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// A specific user
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
// A user's posts, filtered
useQuery({
queryKey: ['user', userId, 'posts', { status: 'published' }],
queryFn: () => fetchUserPosts(userId, 'published'),
});
When you invalidate ['user', userId], React Query also invalidates everything nested under that key — including ['user', userId, 'posts', ...]. This makes cache management feel intuitive rather than brittle.
queryFn: Bring Your Own FetcherReact Query is agnostic about how you fetch. The queryFn can use fetch, axios, ky, a GraphQL client, or any async function that returns data or throws an error:
// With axios
queryFn: () => axios.get(`/api/users/${userId}`).then(r => r.data)
// With a GraphQL client
queryFn: () => graphqlClient.request(USER_QUERY, { userId })
// With tRPC
queryFn: () => trpc.user.byId.query({ userId })
React Query implements the stale-while-revalidate (SWR) pattern. When you navigate back to a screen whose data is already cached, React Query:
You control how long data is considered "fresh" with staleTime:
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // data is fresh for 5 minutes
});
With staleTime: Infinity, data is never automatically refetched — useful for static reference data like country lists or configuration.
Failed requests are automatically retried 3 times (with exponential backoff) before the query enters the error state. Fully configurable:
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
retry: 2,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
When a user tabs away and comes back, React Query refetches stale queries automatically. This means your users almost never see stale data without you writing a single line of polling logic. Disable it per-query or globally if needed:
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
refetchOnWindowFocus: false,
});
If two components mount at the same time and both call useQuery with the same key, React Query sends exactly one request and shares the result with both. This is automatic and requires no coordination on your part.
useMutationReading data is only half the story. The useMutation hook handles write operations:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UpdateUsernameForm({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUsername: string) =>
fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ username: newUsername }),
}).then((r) => r.json()),
onSuccess: () => {
// Invalidate the cache so the updated user is refetched
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
return (
<button
onClick={() => mutation.mutate('new_username')}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Saving...' : 'Update Username'}
</button>
);
}
After a successful mutation, invalidateQueries marks the relevant cache entries as stale, triggering a background refetch. The UI stays consistent without manual state updates.
For a snappier UX, you can update the cache before the server responds and roll back if the request fails:
const mutation = useMutation({
mutationFn: updateUsername,
onMutate: async (newUsername) => {
// Cancel any in-flight refetches
await queryClient.cancelQueries({ queryKey: ['user', userId] });
// Snapshot the current value
const previousUser = queryClient.getQueryData(['user', userId]);
// Optimistically update the cache
queryClient.setQueryData(['user', userId], (old: User) => ({
...old,
username: newUsername,
}));
// Return the snapshot for rollback
return { previousUser };
},
onError: (err, newUsername, context) => {
// Roll back on error
queryClient.setQueryData(['user', userId], context?.previousUser);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
The result: the UI updates instantly, and if the server rejects the change, it snaps back transparently.
React Query ships first-class support for paginated and infinite-scroll data via useInfiniteQuery:
import { useInfiniteQuery } from '@tanstack/react-query';
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts({ page: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
initialPageParam: 1,
});
const posts = data?.pages.flatMap((page) => page.items) ?? [];
return (
<>
{posts.map((post) => <PostCard key={post.id} post={post} />)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
</>
);
}
React Query handles merging pages, tracking cursors, and managing the loading state for each page individually — no custom reducer required.
The @tanstack/react-query-devtools package adds a floating panel to your app in development that shows:
fresh, stale, fetching, paused, inactive)For debugging caching behavior — especially as applications grow — the DevTools are indispensable. They make the invisible cache visible.
| Feature | React Query | SWR | Redux Toolkit Query | Apollo Client |
|---|---|---|---|---|
| Protocol | Any | Any | Any | GraphQL only |
| Cache control | Fine-grained | Basic | Fine-grained | Fine-grained |
| Mutations | First-class | Manual | First-class | First-class |
| Optimistic updates | Built-in | Manual | Built-in | Built-in |
| Infinite queries | Built-in | Built-in | Manual | Manual |
| DevTools | Excellent | Basic | Redux DevTools | Apollo Studio |
| Bundle size | ~13 kB | ~4 kB | Included in RTK | ~30 kB |
| Learning curve | Moderate | Low | Moderate (Redux) | High |
SWR (by Vercel) is React Query's closest competitor. It's lighter and simpler, making it a strong choice for smaller projects or Next.js apps with basic fetching needs. React Query wins in complex scenarios requiring fine-grained cache control, rich mutation lifecycle hooks, or sophisticated pagination.
Redux Toolkit Query (RTK Query) is a strong contender if you're already using Redux. It integrates tightly with the Redux store and DevTools. If you're not already on Redux, the overhead of adopting it for server state alone is rarely justified.
React Query shines in applications with:
useInfiniteQuery is genuinely excellentYou might not need React Query if:
With the Next.js App Router and React Server Components (RSC), some of the problems React Query solves can be addressed server-side: you can fetch data directly in Server Components without any client-side state management.
However, React Query remains highly relevant for:
useMutation is not replicated by RSCThe two paradigms are complementary. Fetch static or initial data on the server; use React Query for dynamic, interactive, and mutation-heavy workflows on the client.
React Query doesn't add a new paradigm to React — it completes the one that was already there. React handles UI state beautifully. React Query handles server state with the same elegance.
The cognitive overhead of data fetching — loading states, error handling, caching, deduplication, retries, invalidation — dissolves into a handful of well-designed hooks. Your components become leaner. Your bugs become fewer. Your users see faster, more consistent UIs.
If you're writing useEffect + useState to fetch data in 2025, you're solving a problem that's already been solved. React Query is the solution the community converged on — and for good reason.
Start with useQuery. Add useMutation when you need to write. Let the cache do the rest.
Resources: TanStack Query Docs · TanStack Query GitHub · Practical React Query by TkDodo

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More