If you've built a React app of any complexity, you've written the same boilerplate a dozen times: a useEffect to fetch data, useState for loading and error, manual cache invalidation when something changes. React Query replaces all of that with a small, well-designed API that handles caching, background refetching, and stale data management out of the box.

What is React Query?

React Query is a library for fetching, caching, and updating server state in React applications. It doesn't replace Redux or Zustand for client-side state; it handles a different category of state entirely. Server state lives on a remote system, can become stale, and needs synchronization. React Query is built specifically for that problem.

By handling caching and background updates internally, it reduces the amount of state you manage manually and makes your components easier to reason about.

Why Use React Query?

Managing data fetching yourself means tracking at minimum three pieces of state per request: loading, error, and data. Add pagination, refetch on window focus, deduplication of concurrent requests, and retry logic and you have hundreds of lines of infrastructure for a problem that isn't your product. React Query solves all of these at the library level.

Specific advantages:

  • When multiple components request the same data simultaneously, React Query deduplicates the network request and shares the result.
  • Data refetches automatically in the background when it becomes stale, so users see fresh content without a manual reload.
  • Optimistic updates and cache invalidation keep the UI consistent after mutations without complex synchronization code.

Getting Started with React Query

Installation:

npm install @tanstack/react-query

Before using React Query hooks, set up a QueryClient and wrap your application with a QueryClientProvider:

import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* ... your app components */}
    </QueryClientProvider>
  );
}

export default App;

Understanding Queries and Mutations

React Query provides two primary hooks: useQuery for fetching data and useMutation for creating, updating, or deleting data.

The useQuery Hook:

import { useQuery } from '@tanstack/react-query';

function Todos() {
  const { isLoading, error, data } = useQuery(['todos'], fetchTodos);

  if (isLoading) return 'Loading...';

  if (error) return 'An error occurred';

  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

The useMutation Hook:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddTodo() {
  const queryClient = useQueryClient();
  const mutation = useMutation(addTodo, {
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries(['todos']);
    },
  });

  return (
    <button
      onClick={() => {
        mutation.mutate({ title: 'New Todo' });
      }}
    >
      Add Todo
    </button>
  );
}

Setting Up a Complete Use Case

Here's a basic CRUD app for managing a list of users, using the JSONPlaceholder API.

The application handles: displaying a list of users, adding a new user, updating an existing user, and deleting a user.

Setting Up the Project:

npx create-react-app react-query-demo
cd react-query-demo
npm install @tanstack/react-query axios

We'll use Axios for making HTTP requests.

Implementing React Query in the Application:

  1. Setup QueryClient
// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);
  1. Fetch Users
// App.js
import React from 'react';
import axios from 'axios';
import { useQuery } from '@tanstack/react-query';

function App() {
  const { isLoading, error, data } = useQuery(['users'], () =>
    axios.get('https://jsonplaceholder.typicode.com/users').then(res => res.data)
  );

  if (isLoading) return <p>Loading users...</p>;

  if (error) return <p>An error occurred: {error.message}</p>;

  return (
    <div>
      <h1>User List</h1>
      <ul>
        {data.map(user => (
          <li key={user.id}>
            {user.name}
            {/* Update and Delete buttons will go here */}
          </li>
        ))}
      </ul>
      {/* Components for adding a user will go here */}
    </div>
  );
}

export default App;
  1. Add a User

Since JSONPlaceholder doesn't actually change data, we'll simulate adding a user.

// AddUser.js
import React, { useState } from 'react';
import axios from 'axios';
import { useMutation, useQueryClient } from '@tanstack/react-query';

function AddUser() {
  const queryClient = useQueryClient();
  const [name, setName] = useState('');

  const addUserMutation = useMutation(
    newUser => axios.post('https://jsonplaceholder.typicode.com/users', newUser).then(res => res.data),
    {
      onSuccess: data => {
        // Update the users list
        queryClient.setQueryData(['users'], oldData => [...oldData, data]);
      },
    }
  );

  const handleSubmit = e => {
    e.preventDefault();
    addUserMutation.mutate({ name });
    setName('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} placeholder="Name" required />
      <button type="submit">Add User</button>
    </form>
  );
}

export default AddUser;

Include <AddUser /> in your App component.

// App.js
// ...
import AddUser from './AddUser';
// ...
function App() {
  // existing code
  return (
    <div>
      {/* existing code */}
      <AddUser />
    </div>
  );
}
// ...
  1. Update a User
// UpdateUser.js
import React, { useState } from 'react';
import axios from 'axios';
import { useMutation, useQueryClient } from '@tanstack/react-query';

function UpdateUser({ user }) {
  const queryClient = useQueryClient();
  const [name, setName] = useState(user.name);

  const updateUserMutation = useMutation(
    updatedUser => axios.put(`https://jsonplaceholder.typicode.com/users/${user.id}`, updatedUser).then(res => res.data),
    {
      onSuccess: data => {
        queryClient.setQueryData(['users'], oldData =>
          oldData.map(u => (u.id === user.id ? data : u))
        );
      },
    }
  );

  const handleSubmit = e => {
    e.preventDefault();
    updateUserMutation.mutate({ name });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} required />
      <button type="submit">Update</button>
    </form>
  );
}

export default UpdateUser;

Include <UpdateUser user={user} /> in your user list rendering.

// App.js
// ...
<li key={user.id}>
  {user.name}
  <UpdateUser user={user} />
  {/* Delete button */}
</li>
// ...
  1. Delete a User
// DeleteUser.js
import React from 'react';
import axios from 'axios';
import { useMutation, useQueryClient } from '@tanstack/react-query';

function DeleteUser({ userId }) {
  const queryClient = useQueryClient();

  const deleteUserMutation = useMutation(
    () => axios.delete(`https://jsonplaceholder.typicode.com/users/${userId}`),
    {
      onSuccess: () => {
        queryClient.setQueryData(['users'], oldData =>
          oldData.filter(user => user.id !== userId)
        );
      },
    }
  );

  return <button onClick={() => deleteUserMutation.mutate()}>Delete</button>;
}

export default DeleteUser;

Include <DeleteUser userId={user.id} /> in your user list rendering.

// App.js
// ...
<li key={user.id}>
  {user.name}
  <UpdateUser user={user} />
  <DeleteUser userId={user.id} />
</li>
// ...

Advanced Features

Handling Query States:

React Query provides various states to manage loading, error, and success scenarios effectively.

const {
  isLoading,
  isError,
  data,
  error,
  isFetching,
  refetch,
} = useQuery(['todos'], fetchTodos);
  • isLoading: true on the first load before data arrives.
  • isError: true if the query encountered an error.
  • data: the fetched result.
  • error: the error object when the query fails.
  • isFetching: stays true during background refetches even when data is already in cache.
  • refetch: call this to manually trigger a refetch.

Caching Strategies:

useQuery(['users'], fetchUsers, {
  cacheTime: 1000 * 60 * 10, // Cache data for 10 minutes
  staleTime: 1000 * 60 * 5,  // Data is fresh for 5 minutes
});

Polling or Interval Fetching:

useQuery(['notifications'], fetchNotifications, {
  refetchInterval: 1000 * 60, // Refetch every 60 seconds
});

Retrying Failed Queries:

useQuery(['users'], fetchUsers, {
  retry: 3, // Retry up to 3 times
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});

React Query Devtools:

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    // ...
    <ReactQueryDevtools initialIsOpen={false} />
  );
}

Server-Side Rendering:

// pages/_app.js
// Implement getServerSideProps or getStaticProps

Custom Query Functions:

const fetcher = url => axios.get(url).then(res => res.data);

useQuery(['users'], () => fetcher('/api/users'));

Transforming Data:

useQuery(['users'], fetchUsers, {
  select: data => data.map(user => user.name),
});

Best Practices

  • Use descriptive, structured query keys: ['users', userId] is easier to invalidate selectively than 'user'.
  • Handle errors with onError callbacks and surface them to the user explicitly.
  • Use pagination or infinite scroll for large data sets to avoid loading everything at once.
  • Tune staleTime per query. Data that changes rarely can have a high staleTime; real-time data should have zero.

Alternatives to React Query

SWR, developed by Vercel, covers similar ground with a simpler API but fewer advanced features. Apollo Client is worth considering if you're already on GraphQL and want integrated cache normalization.

Conclusion

React Query removes a substantial category of boilerplate from React applications. After integrating it, you'll find that most of your useEffect data-fetching logic disappears, and the components that remain are easier to test and reason about. The learning curve is the query key system and understanding when to invalidate versus optimistically update, but both become intuitive quickly.

Further Reading