Quick Answer

TanStack Query (formerly React Query) is a data-fetching library for React. Instead of tracking loading, error, and data by hand in useEffect, you call useQuery({ queryKey, queryFn }) and get all three back, plus caching, request deduplication, and automatic background refetching. You still write the fetch call itself - Query manages everything around it.

What the useEffect pattern misses

Here is the data-fetching code almost every React tutorial starts with:

function useUser(id) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    fetch(`/api/users/${id}`)
      .then((r) => r.json())
      .then((json) => { if (!ignore) { setData(json); setLoading(false); } })
      .catch((e) => { if (!ignore) { setError(e); setLoading(false); } });
    return () => { ignore = true; };
  }, [id]);

  return { data, loading, error };
}

It works, but it has no caching (every mount refetches from scratch), no deduplication (two components asking for the same user make two requests), no background refresh, and no shared state (each component keeps its own copy). The ignore flag is there to dodge a race condition and a "set state on an unmounted component" warning - and you have to remember it every single time. TanStack Query solves all of this once, for every query.

Setup: QueryClient and provider

Install the package and wrap your app once:

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Dashboard />
    </QueryClientProvider>
  );
}

The QueryClient holds the cache, and every component rendered inside the provider can call Query's hooks. Create exactly one client for the whole app - putting it inside a component would throw the cache away on every render, so define it at module scope or with useState(() => new QueryClient()). You can set defaults on it, such as new QueryClient({ defaultOptions: { queries: { staleTime: 60000 } } }), and change them per query later.

While you are learning, add the @tanstack/react-query-devtools panel next to the provider. It shows every cached query, its status, and its current data, so the cache stops being invisible - you can watch entries go stale and refetch as you click around. It is stripped from production builds automatically.

useQuery: one hook, every state

useQuery takes a query key and a query function:

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

function UserCard({ id }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["user", id],
    queryFn: async () => {
      const res = await fetch(`/api/users/${id}`);
      if (!res.ok) throw new Error("Failed to load user");
      return res.json();
    },
  });

  if (isPending) return <p>Loading...</p>;
  if (isError) return <p>{error.message}</p>;
  return <h2>{data.name}</h2>;
}

The queryKey is an array that uniquely identifies this data in the cache. The queryFn is any async function that returns the data or throws. Back you get data, isPending, isError, error, isFetching, and a refetch function.

Version 5 renamed the status flags, and the distinction matters: isPending means there is no cached data yet, isFetching is true during any request including a silent background refresh, and isLoading is the two combined - a first load with nothing to show. Use isPending for the initial spinner and isFetching for a subtle "refreshing" indicator.

Query keys, caching, and staleTime

The query key is the cache identity. Two components that call useQuery with ["user", 1] share one request and one cached result. That is why the key must include every variable the query function uses:

// Wrong - id is not in the key
useQuery({ queryKey: ["user"], queryFn: () => getUser(id) });

// Right
useQuery({ queryKey: ["user", id], queryFn: () => getUser(id) });

With the broken version, switching from user 1 to user 2 shows user 1's data, because both reads hit the same cache entry ["user"]. Include the id and each user gets its own slot, cached independently.

The other surprise is staleTime, which defaults to 0. That means data is considered stale the instant it arrives, so Query refetches it every time the component remounts or the window regains focus. People read this as "the cache is not working." It is - the refetch is a background update, and the cached data shows instantly while it runs. If the data does not change every second, set staleTime to 60000 or more and the refetches stop. A separate setting, gcTime (default 5 minutes), controls how long unused data stays in memory after the last component using it unmounts.

Mutations: changing server data

useQuery is for reading. For writes - POST, PUT, DELETE - use useMutation:

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

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

  const mutation = useMutation({
    mutationFn: (text) =>
      fetch("/api/todos", {
        method: "POST",
        body: JSON.stringify({ text }),
      }).then((r) => r.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  return (
    <button onClick={() => mutation.mutate("Learn Query")}>
      {mutation.isPending ? "Adding..." : "Add"}
    </button>
  );
}

Call mutation.mutate(value) to run it. The important part is onSuccess: after the write lands, the cached ["todos"] list is out of date, so invalidateQueries marks it stale and Query refetches it automatically. Any component showing that list updates itself. You never call setState and you never manually splice the new item into an array - you tell Query the data changed and let it re-sync.

fetch does not throw on 404

This one quietly disables your error handling. fetch() only rejects on a network failure - DNS, no connection, CORS. An HTTP 404 or 500 resolves normally, with res.ok set to false. So this query function never triggers an error state:

// Broken: a 404 becomes "successful" data
queryFn: async () => {
  const res = await fetch(`/api/users/${id}`);
  return res.json(); // parses the error page as if it were a user
}

Query has no way to know the request failed, so isError stays false and your component renders whatever the error response body happened to contain. You have to check res.ok and throw yourself:

queryFn: async () => {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

Libraries like axios and ky reject on non-2xx responses by default, which is why their users never hit this. If you use bare fetch, the res.ok check is not optional.

Frequently Asked Questions

Is TanStack Query a replacement for Redux? No. Query manages server data - fetching, caching, syncing. Redux or Zustand manage client state like UI toggles and form drafts. Many apps use Query for server data and skip a global store entirely.
Do I still need useState with TanStack Query? Yes, for local UI state - input values, open/closed panels, the selected tab. Query only owns data that lives on a server.
Why does my query refetch when I click back into the browser tab? That is refetchOnWindowFocus, on by default. With staleTime at 0 (also the default) the data is stale, so it refetches. Raise staleTime or disable the option.
What is the difference between isLoading and isPending in v5? isPending means there is no cached data yet. isLoading is isPending and isFetching at the same time - specifically the first load. A background refetch sets isFetching only, so isLoading stays false.
Can I use TanStack Query without React? The core, @tanstack/query-core, is framework-agnostic, and there are official adapters for Vue, Svelte, Solid, and Angular.