The Problem with Manual Data Fetching

If you've ever fetched data in React using useEffect, you know it can get complicated fast. You need to manage multiple states yourself:

  • Is the data currently loading? (isLoading)
  • Did an error occur? (error)
  • Where is the actual data? (data)
  • Is the data "stale" and needs to be refetched?
  • How do I re-fetch when the user re-focuses the window?

This leads to a lot of repetitive and error-prone boilerplate code in your components.

TanStack Query: A Server-State Manager

TanStack Query is not a global state manager like Redux or Zustand. It doesn't care about your client state (like whether a modal is open). It excels at managing server state: data that lives on your server and is fetched asynchronously.

It handles all the complexity for you, giving you a simple hook-based API to declare your data needs.

The useQuery Hook: Fetching Data

The useQuery hook is the workhorse for fetching (GET) data. It only needs two main things:

  1. A unique queryKey: An array that uniquely identifies this piece of data.
  2. A queryFn: An async function that fetches and returns the data.

JavaScript


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

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

// 2. Provide the client to your app
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Todos />
    </QueryClientProvider>
  );
}

// 3. Fetch data in a component
function Todos() {
  // `useQuery` handles all the state management for us
  const { data, error, isLoading } = useQuery({
    queryKey: ['todos'], // A unique key for this query
    queryFn: async () => { // The function to fetch data
      const { data } = await axios.get('https://api.example.com/todos');
      return data;
    },
  });

  if (isLoading) return 'Loading...';
  if (error) return 'An error has occurred: ' + error.message;

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

Caching: Behind the scenes, TanStack Query automatically caches the data from this query using ['todos'] as the key. If another component uses useQuery with the same key, it will instantly receive the cached data while a fresh fetch happens in the background (the "stale-while-revalidate" strategy).

The useMutation Hook: Changing Data

For any action that changes data on the server (POST, PUT, DELETE), you use the useMutation hook.

After a mutation succeeds, the data we have on our client is now out of date. For example, if we add a new todo, our ['todos'] query is now stale. We need to tell TanStack Query to refetch it. This is called invalidation.

Code Example:

JavaScript


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

function AddTodoForm() {
  // Get the query client instance
  const queryClient = useQueryClient();

  // Create the mutation
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      // The function that performs the API call
      return axios.post('/api/todos', newTodo);
    },
    onSuccess: () => {
      // When the mutation is successful, invalidate the 'todos' query.
      // This will cause any component using that query to automatically refetch.
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      console.log("Todo added and list invalidated!");
    },
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const title = e.target.elements.title.value;
    mutation.mutate({ title }); // Execute the mutation
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Adding...' : 'Add Todo'}
      </button>
    </form>
  );
}