Back to blog

Mastering State Management with TanStack Query: A Practical Guide

Published May 19, 2025
Updated May 19, 2025
8 min read

A deep dive into TanStack Query, a powerful tool for modern state management in web applications, with practical, real-world examples to help you master it.

Hey there, fellow developers! 👋 Ever found yourself wrestling with complex data fetching, caching, and synchronization in your React applications? You’re not alone. Managing server state can quickly turn into a tangled mess of useEffect hooks, useState variables, and custom logic. It’s a problem that every modern web developer faces, and it’s one that a powerful tool, TanStack Query, is designed to solve.

In this deep dive, we’ll explore what TanStack Query is, why it’s a game-changer, and how you can master it with practical, real-world examples. Get ready to level up your state management skills! 🚀

What is TanStack Query? A Paradigm Shift 🚀

Before we jump into the code, let’s understand the core concept behind TanStack Query (formerly React Query). It’s not a global state management library like Redux or Zustand, though it can work alongside them. Instead, TanStack Query is a dedicated server state management library.

Server state is the data that you get from your server and don’t own. It lives on a remote server, is a shared resource, and can be changed by others at any time.

Think about it: user profiles, product lists, blog posts—this data is “stale” the moment it’s fetched. You need to handle caching, background refetching, and synchronization. This is where TanStack Query shines. It takes a declarative approach, managing all the hard parts for you:

  • Caching: It automatically caches fetched data, so your app feels lightning-fast.
  • Background Refetching: It intelligently refetches data in the background, ensuring your UI is always up-to-date.
  • Deduping Requests: It prevents multiple identical requests from being sent simultaneously.
  • Stale-While-Revalidate: It serves cached data instantly while a fresh request is being made.
  • Mutation and Synchronization: It provides an elegant way to handle data mutations (POST, PUT, DELETE) and automatically invalidates outdated queries.

By offloading these responsibilities, you can focus on building your UI, not on managing isLoading and error states manually.

Getting Started: The Basic useQuery Hook 🪄

Let’s start with the bread and butter of TanStack Query: the useQuery hook. Imagine we’re building a simple blog application and need to fetch a list of posts.

First, install the library (and its devtools, which are a must-have!):

npm install @tanstack/react-query @tanstack/react-query-devtools

Next, wrap your application with the QueryClientProvider to make the client available to all components.

// src/main.jsx or src/App.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// Create a client
const queryClient = new QueryClient();

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

Now, let’s create a component to fetch and display our blog posts.

// src/components/PostsList.jsx
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

const fetchPosts = async () => {
  const response = await axios.get('https://api.vberkoz.com/posts');
  return response.data;
};

const PostsList = () => {
  const { data: posts, 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 (
    <div>
      <h1>Blog Posts</h1>
      {posts.map((post) => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body.substring(0, 100)}...</p>
        </div>
      ))}
    </div>
  );
};

export default PostsList;

Look at the magic! ✨ TanStack Query handles the entire lifecycle for us:

  1. isLoading: True while the fetch is in progress.
  2. isError & error: True if the fetch fails, providing the error object.
  3. data: Holds the fetched posts once the request is successful.
  4. queryKey: ['posts']: This is crucial. It acts as a unique identifier for the query. TanStack Query uses it to cache the data. If another component anywhere in your app calls useQuery with the same key, it will get the cached data immediately. No extra network request! 🤯

Dependent Queries: Fetching Data in Sequence 🔗

The beauty of useQuery goes far beyond a simple fetch. Let’s look at more advanced scenarios. What if you need to fetch a user’s details and then, based on that user’s ID, fetch their specific projects? This is a common pattern.

// src/components/UserProfile.jsx
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

const fetchUser = async (userId) => {
  const response = await axios.get(`https://api.vberkoz.com/users/${userId}`);
  return response.data;
};

const fetchUserProjects = async (userId) => {
  const response = await axios.get(`https://api.vberkoz.com/users/${userId}/projects`);
  return response.data;
};

const UserProfile = ({ userId }) => {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  const { data: projects, isLoading: isLoadingProjects } = useQuery({
    queryKey: ['projects', userId],
    queryFn: () => fetchUserProjects(userId),
    enabled: !!user, // This is the key! 
  });

  if (!user) {
    return <div>Loading user...</div>;
  }

  return (
    <div>
      <h2>User Profile: {user.name}</h2>
      {isLoadingProjects ? (
        <div>Loading projects...</div>
      ) : (
        <ul>
          {projects.map(project => (
            <li key={project.id}>{project.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default UserProfile;

Notice the enabled: !!user option. This is a powerful feature that makes the second query for projects dependent on the first one. The projects query will not run until the user data is available. No more if (user) { ... } spaghetti code! 🍝

Mastering Data Mutations with useMutation 🛠️

Fetching data is only half the story. What about creating, updating, or deleting data? This is where useMutation comes in. It’s designed specifically for asynchronous server-side effects.

Let’s build a form to create a new blog post.

// src/components/CreatePostForm.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useState } from 'react';

const createPost = async (newPost) => {
  const response = await axios.post('https://api.vberkoz.com/posts', newPost);
  return response.data;
};

const CreatePostForm = () => {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      // Invalidate and refetch the 'posts' query to update the UI
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      setTitle('');
      setBody('');
      alert('Post created successfully!');
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    mutation.mutate({ title, body, userId: 1 });
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>Create New Post</h2>
      <div>
        <label>Title:</label>
        <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} />
      </div>
      <div>
        <label>Body:</label>
        <textarea value={body} onChange={(e) => setBody(e.target.value)} />
      </div>
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Post'}
      </button>
      {mutation.isError && <div>An error occurred: {mutation.error.message}</div>}
    </form>
  );
};

export default CreatePostForm;

This simple example showcases the power of useMutation:

  1. mutationFn: createPost: The function that performs the side effect.
  2. mutation.mutate(newPost): The function you call to trigger the mutation. You can pass a payload directly to it.
  3. onSuccess: A callback that runs after a successful mutation. This is where the magic happens!
  4. queryClient.invalidateQueries({ queryKey: ['posts'] }): This is the key to automatic UI synchronization. It tells TanStack Query that the data for the ['posts'] key is now stale and should be refetched the next time it’s accessed. This ensures that our PostsList component automatically updates with the new post. No need for manual state updates or complicated prop drilling. Just invalidate, and let TanStack Query handle the rest. 🧠

Optimistic Updates: The UX Gold Standard ✨

For an even better user experience, we can perform optimistic updates. This means we update the UI before the server responds, assuming the mutation will be successful. If it fails, we roll back to the previous state. This makes the UI feel incredibly fast and responsive.

Let’s add an optimistic update to our post creation.

// Inside the CreatePostForm component, modify the useMutation hook
const mutation = useMutation({
  mutationFn: createPost,
  
  onMutate: async (newPost) => {
    // Cancel any outgoing refetches (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries({ queryKey: ['posts'] });
    
    // Snapshot the previous value
    const previousPosts = queryClient.getQueryData(['posts']);
    
    // Optimistically update to the new value
    queryClient.setQueryData(['posts'], (old) => [
      ...old,
      { ...newPost, id: 'temp-id' }, // Add a temporary ID
    ]);
    
    // Return a context object with the snapshot
    return { previousPosts };
  },
  
  // If the mutation fails, use the context returned from onMutate to roll back
  onError: (err, newPost, context) => {
    queryClient.setQueryData(['posts'], context.previousPosts);
    alert('Failed to create post. Rolling back.');
  },
  
  // Always refetch after error or success to ensure data is in sync with server
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

This might seem like a lot of code, but it’s a powerful and reusable pattern. You can create a custom hook to encapsulate this logic, making it easy to use across your application.

The onMutate function receives the same variables as the mutate function and is called before the mutation function. It’s the perfect place to update your cache optimistically.

Going Further: Custom Hooks and Reusability ♻️

To keep your codebase clean and maintainable, it’s a best practice to encapsulate your data fetching logic into custom hooks.

// src/hooks/usePosts.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

const fetchPosts = async () => {
  const response = await axios.get('https://api.vberkoz.com/posts');
  return response.data;
};

const createPost = async (newPost) => {
  const response = await axios.post('https://api.vberkoz.com/posts', newPost);
  return response.data;
};

export const usePosts = () => {
  return useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });
};

export const useCreatePostMutation = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
};

Now, in any component, you can simply call usePosts() and get the data with all the caching and state management benefits, without needing to repeat the logic.

// A simplified component
import { usePosts } from '../hooks/usePosts';

const PostsListSimplified = () => {
  const { data, isLoading, isError } = usePosts();

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error!</div>;

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

This approach promotes a clean separation of concerns: your components only handle UI logic, while your custom hooks manage data fetching and state.

Conclusion: Embrace the Change 🌟

TanStack Query is more than just a data fetching library; it’s a comprehensive solution for managing server state in a declarative, efficient, and enjoyable way. By moving away from manual useEffect chains and embracing a system that handles caching, background updates, and synchronization for you, you’ll find your code becomes cleaner, more robust, and easier to maintain.

Start small, integrate it into a new feature, and see how much easier it is to reason about your application’s data flow. The a-ha moment will be when you realize you no longer need to manually manage isLoading and error states, or worry about refetching data after a mutation.

So, go ahead. Give TanStack Query a try. Your future self—and your team—will thank you for it! Happy coding! 💻✨