Introduction: The Nightmare of Manual Data Fetching
If you have ever built a medium-to-large scale React application, you know the pain of managing data from an API. Traditionally, we used useEffect combined with useState to fetch data. It usually looks something like this: you initialize a loading state, an error state, and a data state. You trigger the fetch on component mount, handle the promise, and toggle the states accordingly.
While this works for a simple “Hello World” app, it quickly falls apart in production. Why? Because fetching data is only 10% of the problem. The real challenge lies in server state management. How do you handle caching? How do you prevent duplicate requests if two components need the same data? How do you update the UI immediately when a user submits a form (optimistic updates)? How do you handle background refetching when a user switches browser tabs?
Managing this manually leads to “spaghetti code,” bugs, and a poor user experience. This is exactly where React Query (now TanStack Query) shines. It is not just a data-fetching library; it is a powerful state management tool specifically designed to handle server state, making your application feel snappy, reliable, and incredibly fast.
What is React Query (TanStack Query)?
React Query is often described as the missing data-fetching library for React. In technical terms, it is an asynchronous state management library. While libraries like Redux or Zustand are great for client state (like whether a sidebar is open), React Query is built for server state.
Server State is different from client state because:
- It is persisted remotely and you do not control it.
- It requires asynchronous APIs for fetching and updating.
- It can become “stale” or out of date if another user changes it.
TanStack Query takes care of the caching, synchronization, and updating of this server state, allowing you to write declarative code that focuses on the UI rather than the plumbing of network requests.
Why Should You Use It?
Before we dive into the code, let’s look at the “Real World” benefits of using TanStack Query over traditional methods:
1. Out-of-the-Box Caching
Imagine a user clicks on a profile page, goes back to the dashboard, and then clicks on the profile page again. Without React Query, the app fetches the data twice, showing a loading spinner each time. With React Query, the data is cached. The second visit is instantaneous, while a background fetch ensures the data is still up to date.
2. Automatic Background Refetching
If your user leaves your tab to check their email and comes back ten minutes later, your app’s data is likely stale. React Query detects the window focus and automatically refreshes the data in the background, ensuring the user always sees current information.
3. Simplified Code
React Query replaces dozens of lines of useEffect, useState, and error handling logic with a single, clean hook. This reduces your bundle size and makes your components much easier to read and test.
Step 1: Installation and Setup
Setting up React Query is straightforward. First, you need to install the package via npm or yarn.
npm install @tanstack/react-query
# or
yarn add @tanstack/react-query
Once installed, you must wrap your application in the QueryClientProvider. This provider stores the cache and makes it available to all components in your tree.
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
// 1. Create a client
const queryClient = new QueryClient();
function Root() {
return (
// 2. Provide the client to your App
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
}
export default Root;
Core Concept: The Query Key
Before we fetch data, we must understand Query Keys. React Query manages caching based on these keys. Think of a Query Key as a unique ID for your data. If you use the same key in two different components, they will share the same data and loading state.
Query keys are arrays. They can be simple strings or complex objects containing IDs and filters:
['todos']– A global key for all todos.['todo', 5]– A specific key for the todo with ID 5.['todos', { status: 'completed' }]– A key for filtered data.
Fetching Data with useQuery
The useQuery hook is the bread and butter of React Query. It requires at least two arguments: a unique key and a function that returns a promise (the fetcher function).
Basic Example
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
const fetchPosts = async () => {
const response = await axios.get('https://jsonplaceholder.typicode.com/posts');
return response.data;
};
function PostsList() {
// useQuery returns an object with everything we need
const { data, isLoading, isError, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
if (isLoading) return <div>Loading posts...</div>;
if (isError) return <div>An error occurred: {error.message}</div>;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Notice how we didn’t use useEffect? React Query handles the execution of fetchPosts automatically when the component mounts. If the data is already in the cache, it returns it instantly.
The Query Lifecycle: Stale, Inactive, and Deleted
To master React Query, you must understand the states your data goes through. This is where most developers get confused.
- Fresh: The data is new and accurate. React Query will not fetch it again.
- Stale: The data is old. React Query will still show it to the user, but it will trigger a background refetch to get the latest version.
- Fetching: A request is currently in flight.
- Inactive: No components are currently using this query, but the data is still in the cache.
Configuring Stale Time
By default, React Query marks data as stale immediately. This means every time a component re-mounts, it will refetch. You can change this using the staleTime option.
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 1000 * 60 * 5, // Data stays "fresh" for 5 minutes
});
If you set staleTime to 5 minutes, and the user navigates away and back within that window, no network request will be made.
Mutations: Updating Data with useMutation
Queries are for reading data. Mutations are for creating, updating, or deleting data (POST, PUT, DELETE requests).
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
function AddPost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) => {
return axios.post('/api/posts', newPost);
},
onSuccess: () => {
// Invalidate and refetch the 'posts' query to show the new data
queryClient.invalidateQueries({ queryKey: ['posts'] });
alert('Post added successfully!');
},
});
return (
<button
onClick={() => mutation.mutate({ title: 'New Blog Post', body: 'Content' })}
disabled={mutation.isLoading}
>
{mutation.isLoading ? 'Adding...' : 'Add Post'}
</button>
);
}
The invalidateQueries method is crucial. It tells React Query: “Hey, the data for ‘posts’ is now wrong. Please go fetch the latest version from the server.” This keeps your UI in sync with your database.
Optimistic Updates: Building “Pro” User Experiences
Have you noticed how on Twitter, when you like a tweet, the heart turns red immediately before the server even responds? This is an Optimistic Update. It makes the app feel incredibly fast.
React Query makes this easy. Here is the workflow:
- Cancel outgoing refetches for that query (so they don’t overwrite your optimistic state).
- Manually update the cache with the new value.
- If the request fails, roll back the cache to the previous value.
const mutation = useMutation({
mutationFn: updateTodo,
// When mutate is called:
onMutate: async (newTodo) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] });
// Snapshot the previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id]);
// Optimistically update to the new value
queryClient.setQueryData(['todos', newTodo.id], newTodo);
// Return a context object with the snapshotted value
return { previousTodo };
},
// If the mutation fails, use the context we returned above
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos', context.previousTodo.id], context.previousTodo);
},
// Always refetch after error or success:
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] });
},
});
Advanced Patterns: Pagination and Infinite Scroll
React Query provides built-in support for complex data patterns like pagination and infinite scrolling.
Pagination
By using the current page in the queryKey, React Query handles the different “pages” as separate cache entries.
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: (previousData) => previousData, // Keeps old data on screen while loading new page
});
Infinite Scroll
The useInfiniteQuery hook is designed specifically for “Load More” buttons or infinite scrolling feeds. It manages an array of pages and provides a fetchNextPage function.
Common Mistakes and How to Fix Them
1. Not using unique Query Keys
The Mistake: Using ['user'] for both a list of users and a single user profile.
The Fix: Be specific. Use ['users', 'list'] and ['users', 'detail', userId]. This prevents data from overlapping and causing unexpected UI bugs.
2. Ignoring the ‘staleTime’
The Mistake: Leaving staleTime at 0 for data that rarely changes (like a list of countries).
The Fix: Set a higher staleTime. This reduces unnecessary network traffic and improves performance for the user.
3. Putting React Query in a global state like Redux
The Mistake: Trying to copy data from useQuery into a Redux store.
The Fix: Don’t do it! React Query is your state manager for server data. Access the data directly from the hook in any component that needs it. Caching is handled automatically.
Integrating with TypeScript
React Query has excellent TypeScript support. You can define the types for your data and errors to ensure type safety throughout your app.
interface Post {
id: number;
title: string;
}
const { data } = useQuery<Post[], Error>({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// data is now typed as Post[] | undefined
Summary / Key Takeaways
- Server State vs Client State: TanStack Query is for server state (API data), while tools like Zustand/Redux are for client state.
- Caching: Data is cached by its
queryKey. Fresh data is served instantly from the cache. - Declarative: No more manual
useEffectandloadingstates. - Mutations: Use
useMutationfor any side effects (POST/PUT/DELETE) and invalidate queries to keep the UI fresh. - Devtools: Always use the React Query Devtools during development to visualize your cache and query states.
FAQ: Frequently Asked Questions
1. Is React Query a replacement for Axios or Fetch?
No. React Query is a manager. You still use Axios or the native Fetch API inside your queryFn to actually make the network request. React Query just decides when and how to trigger those requests.
2. Does React Query work with GraphQL?
Yes! React Query is agnostic to how you fetch data. As long as your queryFn returns a promise, you can use GraphQL (via graphql-request or apollo-client) just as easily as REST.
3. How do I clear the cache?
You can use queryClient.removeQueries({ queryKey: ['posts'] }) to remove specific items, or queryClient.clear() to wipe the entire cache (useful during user logout).
4. Can I use React Query with React Native?
Absolutely. TanStack Query works perfectly in React Native. Since mobile devices often have unstable network connections, features like “retry on reconnect” and caching are even more valuable there.
5. When should I NOT use React Query?
If your application has almost no server communication, or if you are using a framework like Next.js with strictly static site generation (SSG) where data never changes, React Query might be overkill. However, for 90% of modern web apps, it is a massive productivity booster.
