React Query (TanStack) Tutorial: Fetching, Caching, and Mutations Made Easy

by Didin J. on Aug 03, 2025 React Query (TanStack) Tutorial: Fetching, Caching, and Mutations Made Easy

Learn how to use React Query (TanStack) for effortless data fetching, caching, and mutations in React apps—complete with examples and best practices.

Managing data fetching and state in React apps can quickly become complex and repetitive. Whether you're calling REST APIs or GraphQL endpoints, the traditional useEffect + fetch or Axios pattern often leads to duplicated logic, cluttered components, and poor user experience due to missing caching, loading states, or error handling.

That’s where React Query, now officially part of the TanStack, comes in. React Query is a powerful data-fetching library for React that simplifies and supercharges how you fetch, cache, synchronize, and update server state in your applications — all with minimal boilerplate.

In this tutorial, you’ll learn how to:

  • Fetch and display data using useQuery

  • Handle loading and error states with ease

  • Perform POST, PUT, and DELETE requests with useMutation

  • Use caching and invalidation to keep your UI in sync

  • Debug queries using React Query Devtools

By the end, you’ll have a solid understanding of how to integrate React Query into your frontend projects, making your code cleaner, more scalable, and more responsive.


What is React Query (TanStack Query)?

React Query, now part of the TanStack library suite, is a powerful tool for managing server state in React applications. Unlike client state (which you handle with tools like useState or Redux), server state comes from remote sources — APIs, databases, or services — and often changes outside of your app’s control.

React Query provides a set of hooks that:

  • Fetch and cache server data (useQuery)

  • Update or mutate data (useMutation)

  • Automatically refetch and sync data in the background

  • Handle loading, error, and success states efficiently

  • Enable features like pagination, prefetching, and stale-time caching

🆚 Traditional Data Fetching vs. React Query

Feature Traditional Approach React Query
Data Fetching useEffect + fetch/Axios useQuery, useMutation
Caching Manual (if any) Automatic
Loading/Error State Must be manually handled Built-in
Background Refetching Manual setup Automatic
Performance Often suboptimal Optimized by default

React Query doesn't replace client-side state libraries like Redux or Zustand — instead, it complements them by handling server-side state efficiently.

In short, React Query helps you write less code, build faster UIs, and spend less time debugging data sync issues.


Setting Up the Project

Before diving into React Query’s features, let’s set up a simple React app and install the necessary dependencies.

✅ Prerequisites

Make sure you have the following installed:

  • Node.js (v18 or newer recommended)

  • npm or yarn

  • Basic knowledge of React and functional components

🛠️ 1. Create a New React App

You can use Vite for a faster, modern setup:

npm create vite@latest react-query-demo -- --template react
cd react-query-demo
npm install

Or use Create React App:

npx create-react-app react-query-demo
cd react-query-demo

📦 2. Install React Query and Axios

React Query works with any fetch library. In this tutorial, we’ll use Axios.

npm install @tanstack/react-query axios

You can optionally add React Query Devtools:

npm install @tanstack/react-query-devtools

🌐 3. Choose an API for Demonstration

We’ll use JSONPlaceholder — a free fake REST API — for demonstration purposes.

Example endpoint for fetching posts:

https://jsonplaceholder.typicode.com/posts

🧩 4. Set Up React Query Client

In src/main.jsx or src/index.js, wrap your app with the QueryClientProvider:

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

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);

Optional: Add React Query Devtools inside App.jsx:

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

function App() {
  return (
    <>
      <h1>React Query Demo</h1>
      {/* Your components */}
      <ReactQueryDevtools initialIsOpen={false} />
    </>
  );
}


Basic Data Fetching with useQuery

The useQuery hook is the core of React Query. It allows you to fetch data, manage caching, and handle loading or error states effortlessly.

Let’s fetch and display a list of posts from JSONPlaceholder.

🧱 1. Create an API Utility

Create a new file src/api/posts.js:

import axios from 'axios';

const API_URL = 'https://jsonplaceholder.typicode.com';

export const fetchPosts = async () => {
  const response = await axios.get(`${API_URL}/posts`);
  return response.data;
};

⚓ 2. Use useQuery to Fetch Posts

In src/App.jsx, import the necessary hooks and components:

import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from './api/posts';

function App() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  if (isLoading) return <p>Loading posts...</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {data.map(post => (
          <li key={post.id}>
            <strong>{post.title}</strong>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

🧠 How It Works

  • queryKey: A unique identifier for the cached query (must be an array).

  • queryFn: The async function that fetches the data.

  • data: The fetched data.

  • isLoading, isError, error: States that React Query manages for you.

🗂️ Automatic Caching

React Query caches the result of this fetch. If the user navigates away and returns, the cached data will be shown instantly and refreshed in the background.


Displaying the Data (with Optional Pagination or Filtering)

Now that we’re successfully fetching data with useQuery, let’s improve the user interface and optionally introduce simple pagination or filtering using React state.

📃 Basic List Display (Improved UI)

You can separate the post list into its components for clarity:

function PostList({ posts }) {
  return (
    <ul style={{ padding: 0 }}>
      {posts.map(post => (
        <li key={post.id} style={{ marginBottom: '1rem', listStyle: 'none' }}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </li>
      ))}
    </ul>
  );
}

export default PostList;

Then, update your App.jsx:

import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from './api/posts';
import PostList from './components/PostList';

function App() {
  const { data: posts, isLoading, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  if (isLoading) return <p>Loading posts...</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>React Query Post List</h1>
      <PostList posts={posts} />
    </div>
  );
}

export default App;

🔢 Optional: Simple Client-Side Pagination

Here’s how to display posts page by page (e.g., 10 per page):

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchPosts } from "./api/posts";
import PostList from "./components/PostList";

function App() {
  const [page, setPage] = useState(1);
  const perPage = 10;

  const {
    data: posts,
    isLoading,
    isError,
    error
  } = useQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts
  });

  if (isLoading) return <p>Loading posts...</p>;
  if (isError) return <p>Error: {error.message}</p>;

  const totalPages = Math.ceil(posts.length / perPage);
  const paginatedPosts = posts.slice((page - 1) * perPage, page * perPage);

  return (
    <div>
      <h1>Paginated Posts</h1>
      <PostList posts={paginatedPosts} />
      <div style={{ marginTop: "1rem" }}>
        <button
          onClick={() => setPage((p) => Math.max(p - 1, 1))}
          disabled={page === 1}
        >
          Previous
        </button>
        <span style={{ margin: "0 1rem" }}>
          Page {page} of {totalPages}
        </span>
        <button
          onClick={() => setPage((p) => Math.min(p + 1, totalPages))}
          disabled={page === totalPages}
        >
          Next
        </button>
      </div>
    </div>
  );
}

export default App;


Mutations with useMutation

So far, we've only fetched (read-only) data. But what if you want to create, update, or delete resources? That’s where the useMutation hook shines.

Let’s walk through how to add a new post using useMutation, and then automatically refetch or update the list when done.

🧱 1. Add a Create Post API Function

In src/api/posts.js, add the following:

export const createPost = async (newPost) => {
  const response = await axios.post(`${API_URL}/posts`, newPost);
  return response.data;
};

✍️ 2. Create a Post Form Component

Let’s create a simple form to add a new post:

// src/components/PostForm.jsx
import { useState } from 'react';

function PostForm({ onAdd }) {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onAdd({ title, body });
    setTitle('');
    setBody('');
  };

  return (
    <form onSubmit={handleSubmit} style={{ marginBottom: '2rem' }}>
      <h2>Create a New Post</h2>
      <input
        type="text"
        placeholder="Title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        required
      /><br />
      <textarea
        placeholder="Body"
        value={body}
        onChange={(e) => setBody(e.target.value)}
        required
        rows={4}
      /><br />
      <button type="submit">Add Post</button>
    </form>
  );
}

export default PostForm;

⚓ 3. Use useMutation in App

Update App.jsx:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchPosts, createPost } from './api/posts';
import PostList from './components/PostList';
import PostForm from './components/PostForm';

function App() {
  const queryClient = useQueryClient();

  const { data: posts, isLoading, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // Invalidate and refetch the posts query
      queryClient.invalidateQueries(['posts']);
    },
  });

  const handleAddPost = (newPost) => {
    mutation.mutate(newPost);
  };

  if (isLoading) return <p>Loading posts...</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>React Query CRUD</h1>
      <PostForm onAdd={handleAddPost} />
      {mutation.isPending && <p>Creating post...</p>}
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>Post created!</p>}
      <PostList posts={posts} />
    </div>
  );
}

export default App;

🔁 What’s Happening:

  • useMutation triggers a POST request to add data.

  • On success, we invalidate the ['posts'] query so it’s automatically refetched.

  • You can show feedback using mutation.isPending, mutation.isSuccess, etc.


Devtools for React Query

React Query comes with a powerful set of developer tools to help you inspect your queries, mutations, and cache in real time.

This section will show you how to add and use React Query Devtools to improve your development workflow.

📦 1. Install the Devtools (if you haven’t already)

If you skipped this earlier, install the package:

npm install @tanstack/react-query-devtools

🧩 2. Add Devtools to Your App

Update App.jsx:

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

function App() {
  // ... your existing logic

  return (
    <div>
      {/* Your app components */}
      <PostForm onAdd={handleAddPost} />
      {mutation.isPending && <p>Creating post...</p>}
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>Post created!</p>}
      <PostList posts={posts} />

      {/* Devtools */}
      <ReactQueryDevtools initialIsOpen={false} />
    </div>
  );
}

🧠 What You Can Do with Devtools:

  • See all active queries and their statuses (fresh, stale, fetching, etc.)

  • Refetch queries manually

  • Inspect cache data and query keys

  • Debug errors and background fetching behavior

💡 Devtools are development-only by default and won’t be included in your production builds.


Advanced Features (Optional but Powerful)

Once you’ve mastered the basics, React Query offers many advanced features to help you build efficient, responsive, and user-friendly apps.

Let’s look at some of the most useful capabilities:

🔁 1. Query Invalidation and Background Refetching

React Query automatically refetches stale data when:

  • A component mounts

  • A window refocuses

  • A query is invalidated manually

You can configure this behavior:

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  refetchOnWindowFocus: false,
});
  • staleTime: How long the data stays “fresh” before becoming stale

  • refetchOnWindowFocus: Disable background refetching when switching tabs

🔗 2. Dependent Queries

If one query depends on the result of another (e.g., fetching user details by ID), you can conditionally enable queries:

useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  enabled: !!userId, // Only run if userId exists
});

🔄 3. Optimistic Updates

You can optimistically update the UI before the server confirms success. This improves perceived speed.

useMutation({
  mutationFn: updatePost,
  onMutate: async (updatedPost) => {
    await queryClient.cancelQueries(['posts']);
    const previousPosts = queryClient.getQueryData(['posts']);

    queryClient.setQueryData(['posts'], old =>
      old.map(post => post.id === updatedPost.id ? updatedPost : post)
    );

    return { previousPosts };
  },
  onError: (err, updatedPost, context) => {
    queryClient.setQueryData(['posts'], context.previousPosts);
  },
  onSettled: () => {
    queryClient.invalidateQueries(['posts']);
  },
});

📦 4. Prefetching Queries

You can fetch data before a component mounts using queryClient.prefetchQuery — useful for navigation or hover actions.

queryClient.prefetchQuery({
  queryKey: ['post', postId],
  queryFn: () => fetchPost(postId),
});

These features allow you to fine-tune performance and user experience, especially in large-scale or dynamic apps.


Best Practices for Using React Query

React Query is powerful, but like any tool, it works best when used thoughtfully. Here are some best practices to help you build clean, maintainable, and scalable applications:

📁 1. Separate API Logic from UI

Keep your API functions (fetchPosts, createPost, etc.) in a separate file (e.g., src/api/) to keep components clean and reusable.

// Good structure
src/
├── api/
│   └── posts.js
├── components/
│   ├── PostForm.jsx
│   └── PostList.jsx

🧠 2. Use Stable queryKey Arrays

Always use an array for queryKey. It helps React Query properly cache and refetch data.

useQuery({
  queryKey: ['posts'], // ✅ good
  ...
});

You can include dynamic parts too:

useQuery({
  queryKey: ['post', postId],
  ...
});

🕰️ 3. Adjust Stale Time Strategically

Use staleTime to avoid unnecessary refetching:

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 1000 * 60 * 2, // 2 minutes
});

This improves performance while keeping data fresh enough.

🔁 4. Invalidate Queries After Mutations

Always invalidate or update the query cache when performing mutations:

onSuccess: () => {
  queryClient.invalidateQueries(['posts']);
}

This ensures the UI stays in sync with server data.

⚙️ 5. Wrap with QueryClientProvider Once

Wrap your entire app in a single QueryClientProvider — usually at the root level (main.jsx or index.js).

Avoid wrapping individual components separately.

🧪 6. Use Devtools in Development

Don’t forget to add <ReactQueryDevtools /> when building and debugging. It saves a ton of time.

📉 7. Avoid Over-Fetching

Use conditional fetching (enabled: false) or staleTime to avoid unnecessary API calls.

With these practices, you’ll write better code, reduce bugs, and create faster, more responsive applications.


Conclusion

React Query (TanStack Query) is a game-changer for handling asynchronous server state in modern React applications. It eliminates much of the repetitive boilerplate code involved in fetching and managing data while offering powerful features like:

  • Automatic caching and background updates

  • Mutation handling with rollback and optimistic updates

  • Built-in loading, error, and success states

  • Developer tools for easy debugging

  • Advanced capabilities like prefetching and dependent queries

In this tutorial, you learned how to:

  • Set up React Query in a React project

  • Fetch and cache data using useQuery

  • Perform create operations with useMutation

  • Handle state updates and invalidation

  • Use Devtools and advanced strategies to optimize performance

Whether you're building a simple blog or a complex dashboard, React Query helps you build faster, cleaner, and more maintainable applications.

You can get the full source code on our GitHub.

That's just the basics. If you need more deep learning about React.js, React Native, or related, you can take the following cheap course:

Thanks!