What is React Query? The Missing Data-Fetching Tool for React Apps

Most React applications rely on a combination of useEffect and useState hooks to manage server state. This approach works initially, but it quickly creates a tangled web of logic that becomes increasingly difficult to maintain. React Query offers a different path by providing a declarative way to fetch, cache, and synchronize data in your React applications.
The numbers speak for themselves. Only 2.8% of users report disliking React Query, and just over 44% of developers now prefer it over alternatives for managing server state. These aren't just vanity metrics—they reflect a tool that genuinely solves real problems developers face daily.
This guide will walk you through why React Query has become essential for modern React development. We'll explore what React Query is used for and how it simplifies API interactions by providing caching, synchronization, and server state management out of the box. You'll see practical examples and learn the fundamentals that make React Query the missing piece many developers have been searching for.
Let's examine why traditional data fetching methods fall short and how React Query changes the game entirely.
Why React Needs a Better Data-Fetching Tool
Traditional data fetching in React applications relies on useState and useEffect hooks. This approach appears straightforward initially, but fundamental limitations emerge as applications grow more complex, making this pattern increasingly difficult to manage.
Limitations of useEffect and useState for async data
The React team officially recommends against using useEffect for data fetching in most scenarios. This recommendation stems from several significant drawbacks that affect both application performance and developer experience.
Using async functions directly within useEffect creates immediate problems. The hook expects either nothing or a cleanup function to be returned—not a promise. Attempting to mark the useEffect callback as async results in unintended behavior that can disrupt React's rendering lifecycle.
Managing loading and error states requires multiple state variables that must be carefully synchronized:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);This pattern becomes verbose and error-prone as the number of data sources increases. Memory leaks frequently occur when components unmount before asynchronous operations complete. Without proper cleanup, setting state on unmounted components leads to runtime errors.
Effects don't run on the server, which complicates server-side rendering. Developers must implement different data-fetching strategies for server and client rendering, adding complexity to the development process.
Common pitfalls: race conditions and duplicated requests
Race conditions represent one of the most problematic issues when fetching data with useEffect. These occur when multiple asynchronous operations finish in an unexpected order, potentially displaying outdated or incorrect information.
Consider a search feature where users type quickly. If the first search query takes longer to resolve than subsequent ones, the UI might display results for an earlier query rather than the most recent one. As one developer described it: "You search for Macron, then change your mind and search for Trump, and you end up with a mismatch between what you want (Trump) and what you get (Macron)".
For some applications, these race conditions have serious consequences, such as "a user buying the wrong product, or a doctor prescribing the wrong drug to a patient".
Duplicate requests present another common issue. Browsers typically limit concurrent connections to the same host—Chrome, for instance, allows only 6 parallel requests. When components fire multiple requests simultaneously, this limitation creates a bottleneck where additional requests must wait, significantly impacting performance.
React 18's strict mode compounds these problems. Components mount, unmount, and remount during development, potentially triggering the same data fetching effect twice.
Why managing server state is different from client state
Client state and server state have fundamentally different characteristics that require distinct management approaches. Client state includes form inputs and UI toggles that your application directly controls. Server state consists of data from APIs that exists remotely and requires synchronization.
Server state differs from client state in several critical ways:
- External entities may update it without your application's knowledge
- It requires explicit fetching and updating operations
- It involves network-related concerns like loading states, error handling, and caching
Traditional state management tools like Redux or Context API were designed primarily for client state. They lack built-in features for handling server-state specific challenges.
The combination of useState and useEffect for fetching server data becomes increasingly complex as applications scale. Developers must manually implement loading states, error handling, caching, background updates, and stale data management for each data source.
This complexity explains why specialized tools like React Query have gained popularity. They provide a declarative approach to managing server state with built-in solutions for the challenges described above. Rather than replacing client state management, React Query complements it by focusing specifically on server state requirements.
React Query enables developers to write more maintainable code while avoiding the pitfalls associated with the traditional useState/useEffect approach. This separation of concerns proves essential for building robust, scalable React applications.
What is React Query and What Problem Does It Solve?
TanStack Query (formerly React Query) represents a specialized solution for managing server state in React applications. At its core, it functions as an async state manager rather than merely a data-fetching library. This distinction matters because it shapes how we think about and interact with remote data.
Declarative data fetching with useQuery()
The useQuery hook forms React Query's foundation, enabling developers to fetch data declaratively instead of imperatively. Rather than manually orchestrating state variables and side effects, this hook encapsulates the entire data-fetching process:
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
});This pattern abstracts away complexity while providing immediate access to loading states, error handling, and the fetched data. The queryKey serves as a unique identifier for the query, while the queryFn contains the promise-returning function that retrieves data.
The power of this approach lies in how it transforms asynchronous code from imperative to declarative. This shift eliminates roughly 50% of the boilerplate code typically required with useState/useEffect patterns.
Built-in caching and background updates
React Query's sophisticated caching mechanism tackles one of data management's most challenging aspects. The library automatically caches query results based on their query keys, making subsequent calls to the same endpoint virtually instantaneous.
Here's how the lifecycle works:
- When a component calls
useQuerywith a specific key for the first time, React Query fetches the data and caches it - If another component requests the same data, it receives the cached result immediately while a background refresh occurs
- After all components using a query unmount, the cache persists for a default period (5 minutes) before garbage collection
React Query provides fine-grained control through configuration options:
staleTime: Determines when data needs refreshing (default: immediately)gcTime: Controls how long unused data remains in memory (default: 5 minutes)refetchOnMount,refetchOnWindowFocus,refetchOnReconnect: Intelligently refresh data when appropriate
These features automate data synchronization between client and server with minimal developer intervention.
How React Query simplifies async state management
Server state presents unique challenges due to its asynchronous nature combined with remote persistence—characteristics that traditional state management tools weren't designed to handle efficiently.
React Query addresses this complexity through several key mechanisms:
First, it eliminates race conditions by storing state based on query keys rather than component instances. This prevents outdated data from overwriting newer responses, a common issue with useEffect-based approaches.
Second, it provides automatic background fetching indicators. While status === 'loading' indicates initial loading, the isFetching boolean signals background refreshes, allowing for more nuanced loading states:
{isFetching ? <div>Refreshing...</div> : null}Third, React Query efficiently deduplicates multiple identical requests, preventing redundant network calls even in React's StrictMode. This optimization conserves bandwidth and improves performance, particularly in larger applications.
Finally, the library excels at managing asynchronous state transitions through discriminated unions at the type level, ensuring that loading, error, and success states remain clearly separated.
React Query transforms the cache into a de-facto data store for server state, eliminating the need for complex state management architectures just to handle async data. This paradigm shift allows developers to focus on business logic rather than the intricate mechanics of data synchronization.
Setting Up React Query in Your Project
Setting up React Query takes just a few minutes, but the impact on how you manage server state is immediate. You'll need to install the package, create a QueryClient instance, and wrap your application with a provider. Let's walk through each step.
Installing @tanstack/react-query
React Query was renamed to TanStack Query, so you'll install the package under its current name. Choose your preferred package manager:
npm install @tanstack/react-queryAlternative package managers work just as well:
pnpm add @tanstack/react-query
yarn add @tanstack/react-query
bun add @tanstack/react-queryThis library works with React v18+ and supports both ReactDOM and React Native. If you encounter older tutorials mentioning react-query, remember that starting with v4, the package is now available as @tanstack/react-query.
Creating and configuring QueryClient
The QueryClient manages your application's server state. It serves as the central hub for all queries and provides methods to interact with the cache.
Create a basic QueryClient instance:
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient()For more control over behavior, pass configuration options:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
// Other query defaults
},
},
})Key configuration parameters include:
staleTime: How long data remains fresh before needing a refetchgcTime: How long inactive data stays in cache (default: 5 minutes)refetchOnMount,refetchOnWindowFocus: Automatic refetching behavior
The QueryClient contains methods for prefetching, caching, and invalidating queries that we'll explore in later sections.
Using QueryClientProvider in your app
Once created, make your QueryClient available throughout your application using the QueryClientProvider component. This provider must wrap any components that use React Query hooks.
Add this to your application's root component:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* Your application components */}
</QueryClientProvider>
)
}The QueryClientProvider accepts the client as a prop, which must be an instance of QueryClient. This creates a React context that provides the QueryClient to all nested components.
In typical React applications, place this provider near the root of your component tree:
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<ThemeProvider theme={theme}>
<Routes />
</ThemeProvider>
</Router>
</QueryClientProvider>
)
}Any component within this provider tree can now use React Query hooks like useQuery and useMutation.
As an optional but recommended step, install the ESLint plugin to catch potential bugs:
npm install -D @tanstack/eslint-plugin-queryThis plugin identifies common mistakes and inconsistencies in your React Query implementation.
With React Query set up in your project, you're ready to start fetching data more efficiently. The next section will show you practical examples of the useQuery hook in action.
Fetching Data with useQuery(): A Practical Guide
The useQuery hook represents the cornerstone of React Query's functionality. This single hook replaces the complex state management code that developers typically wrestle with when using traditional methods.
Basic useQuery() syntax and parameters
Two essential parameters power the useQuery hook:
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});The queryKey acts as a unique identifier for caching and tracking your query. While a simple string works, using an array is recommended even for basic queries—it provides flexibility for adding parameters later. React Query uses this key to determine when to refetch data and manage cache invalidation.
The queryFn parameter contains your asynchronous function that fetches data. This function must return a promise that resolves to your data or throws an error:
const fetchPosts = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
};Additional configuration options like enabled, staleTime, and refetchInterval allow you to customize fetching behavior according to your specific requirements.
Handling loading, error, and success states
React Query excels at managing the various states of asynchronous operations. The hook returns an object containing several essential properties:
const {
data, // The fetched data
isLoading, // True during initial load
isError, // True if an error occurred
error, // The error object if present
status, // 'loading', 'error', or 'success'
isFetching // True during any fetch (including background)
} = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });These properties make rendering different UI states remarkably straightforward:
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
// Success state (data is available)
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);For more explicit state handling, you can use the status property:
switch (status) {
case 'loading':
return <div>Fetching posts...</div>;
case 'error':
return <div>An error occurred: {error.message}</div>;
case 'success':
return <DisplayPosts data={data} />;
}The isFetching property proves particularly valuable for displaying background refresh indicators without disrupting the main UI.
React Query examples using JSONPlaceholder API
Here's a complete example using the JSONPlaceholder API to fetch a list of posts:
import { useQuery } from '@tanstack/react-query';
function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
return response.json();
}
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Something went wrong: {error.message}</p>;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Fetching a single post based on an ID requires a more specialized approach:
function SinglePost({ postId }) {
const { data, status } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
.then(res => res.json()),
enabled: !!postId // Only run the query when postId exists
});
if (status === 'loading') return <p>Loading post...</p>;
if (status === 'error') return <p>Error loading post</p>;
return (
<div>
<h1>{data.title}</h1>
<p>{data.body}</p>
</div>
);
}The enabled option demonstrates conditional data fetching based on dependencies—a common requirement in real-world applications.
Mutating Data with useMutation()
Fetching data is only half the story. Applications also need to create, update, and delete server data. The useMutation hook handles these operations with the same elegance that makes React Query so appealing for data fetching.
How useMutation() works for POST, PUT, DELETE
The useMutation hook manages operations that modify data on the server—primarily POST, PUT, PATCH, and DELETE requests. Unlike useQuery which runs automatically, mutations execute only when you explicitly trigger them.
Here's the basic setup:
import { useMutation } from '@tanstack/react-query';
const mutation = useMutation({
mutationFn: (newTodo) => {
return axios.post('/todos', newTodo)
}
});The mutationFn parameter accepts any promise-returning function. This flexibility covers different HTTP methods for various data modification needs:
// POST request to create a resource
const createTodo = useMutation({
mutationFn: (newTodo) => axios.post('/todos', newTodo)
});
// PUT request to update a resource
const updateTodo = useMutation({
mutationFn: (todo) => axios.put(`/todos/${todo.id}`, todo)
});
// DELETE request to remove a resource
const deleteTodo = useMutation({
mutationFn: (id) => axios.delete(`/todos/${id}`)
});Triggering mutations and handling responses
Once defined, trigger a mutation by calling the mutate function with variables:
<button onClick={() => mutation.mutate({ title: 'Do laundry' })}>
Create Todo
</button>The mutation hook provides several states to track progress:
{mutation.isPending && <p>Adding todo...</p>}
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>Todo added!</p>}React Query also provides mutateAsync which returns a promise, enabling await syntax:
const handleSubmit = async () => {
try {
const data = await mutation.mutateAsync(newTodo);
console.log('Success:', data);
} catch (error) {
console.error('Error:', error);
}
};For more controlled execution, pass callback options directly to the mutate function:
mutation.mutate(
{ id: 5, name: 'Do the laundry' },
{
onSuccess: (data) => {
console.log('Success:', data);
},
onError: (error) => {
console.error('Error:', error);
}
}
);Invalidating queries after mutation
Here's where mutations truly shine. After a successful mutation, you typically want to refresh affected data. React Query makes this straightforward with the invalidateQueries method:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function TodoApp() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Invalidate and refetch the todos list
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}Invalidation marks queries as stale, prompting React Query to refetch active queries immediately while only refetching other queries when they're used again.
Alternatively, you can directly update the cache with setQueryData for cases where the mutation response contains the updated data:
const mutation = useMutation({
mutationFn: editTodo,
onSuccess: (data, variables) => {
queryClient.setQueryData(['todo', { id: variables.id }], data);
},
});This approach avoids unnecessary network requests by using the mutation response to update the cache directly, while still maintaining immutability:
// ✅ Correct immutable update
queryClient.setQueryData(['posts', { id }], (oldData) =>
oldData ? { ...oldData, title: 'new title' } : oldData
);Together, useQuery for data fetching and useMutation for data modifications provide a complete solution for managing server state in React applications.
Advanced Features for Real-World Apps
React Query's true strength emerges when you move beyond basic data fetching. Real-world applications demand sophisticated features that traditional approaches simply can't deliver efficiently.
Pagination with keepPreviousData
Anyone who's built paginated interfaces knows the frustration: users click "Next Page" and suddenly see a loading spinner where their data used to be. It's jarring and feels broken.
React Query solves this with the keepPreviousData option. Instead of showing loading states between page transitions, your application continues displaying the previous page's data while fetching the next page in the background. The user experience becomes seamless—no more flickering content.
React Query also provides the isPreviousData flag to help you handle this gracefully. You can disable navigation buttons when previous data is showing, preventing users from clicking through pages faster than data can load.
Infinite scrolling using useInfiniteQuery()
Building infinite scroll interfaces traditionally requires complex state management to track multiple pages of data. The useInfiniteQuery hook eliminates this complexity entirely.
Unlike regular queries, it returns a structured response designed specifically for infinite scrolling:
data.pagesarray containing all fetched pagesfetchNextPagefunction to load more datahasNextPageboolean to determine if more data existsisFetchingNextPageto distinguish between initial and subsequent loads
Here's how straightforward implementation becomes:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage
});The getNextPageParam function tells React Query what parameter to use for the next page request. React Query handles everything else automatically.
Dependent queries with enabled flag
Many applications need to chain queries—fetch user data first, then fetch that user's projects. Traditional approaches lead to complex useEffect chains that are difficult to debug.
React Query handles this elegantly with the enabled option:
const { data: user } = useQuery({ queryKey: ['user', email], queryFn: getUserByEmail });
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: getProjectsByUser,
enabled: !!user?.id
});The projects query waits until user data is available. No complex dependency arrays or effect chains required.
Background refetching and staleTime
React Query automatically keeps your data fresh through intelligent background refetching. It triggers updates when users return to your tab, when their network reconnects, or when components remount.
The staleTime option gives you control over this behavior. Set it to determine how long data remains fresh before background updates begin. Longer stale times reduce unnecessary network requests, while shorter ones ensure data stays current.
This automatic synchronization means your users always see up-to-date information without any additional code on your part.
Conclusion
React Query has proven itself as an essential tool for managing server state in React applications. What started as a solution to the limitations of useState and useEffect has become the preferred choice for over 44% of developers working with server data.
The traditional approach to data fetching creates unnecessary complexity. Race conditions, memory leaks, and verbose state management code plague applications that rely solely on React's built-in hooks. React Query addresses these issues directly through its intuitive useQuery and useMutation hooks, along with a sophisticated caching system that works automatically.
Setup remains remarkably simple. Create a QueryClient, wrap your application with QueryClientProvider, and start using the hooks immediately. This minimal configuration unlocks powerful functionality that would otherwise require extensive custom implementation.
Where React Query truly excels is in complex applications. Features like keepPreviousData for smooth pagination, useInfiniteQuery for infinite scrolling, and conditional queries with the enabled flag turn advanced data patterns into straightforward implementations. The automatic background refetching keeps your application current without manual intervention.
Mutations complete the picture by handling data modifications with the same declarative approach. The cache invalidation system ensures your UI stays synchronized with server state after every change, eliminating the manual coordination typically required.
For developers building React applications that interact with APIs, React Query has become indispensable. It fundamentally changes how we approach server state management, allowing us to focus on features rather than data synchronization mechanics. The improved developer experience and application performance make it a worthwhile addition to any React project handling server data.


