A dashboard of synchronized data cards with a relaxed red maine coon cat resting on a nearby shelf.

TanStack Query: Stop Babysitting Your Server State

The Orange Cat
The Orange Cat

If you have ever written useEffect to fetch data, you know the ritual. You declare a data state, a loading flag, an error flag. You remember to set loading to true, then false in a finally. You forget to handle the race condition where an old request resolves after a new one. You hand-roll caching, then hand-roll the cache invalidation, then quietly give up on both. Every component becomes a tiny, buggy reimplementation of the same async plumbing.

TanStack Query (the library formerly known as React Query) exists to delete all of that. It is an async state management library built to simplify fetching, caching, synchronizing, and updating server state. The key insight is that server state is fundamentally different from the client state your app owns. Your form inputs and modal toggles are synchronous and always correct. Data on a server is just a snapshot you borrowed: it lives remotely, it changes without telling you, and it needs caching, deduplication, and freshness checks. Treating it like ordinary useState is the original sin that produces all that boilerplate. TanStack Query treats server state as its own category and gives it a dedicated tool.

Why It Earns Its 52 Million Weekly Downloads

This is not a discovery piece about an obscure gem. @tanstack/react-query is one of the most-downloaded React libraries in existence, and for good reason. Here is what you get essentially for free.

  • Caching and deduplication. Results are stored by a query key and reused across components. If ten components ask for the same key at once, exactly one network request fires.
  • Stale-while-revalidate. On revisit, cached data renders instantly, then quietly refetches in the background and swaps in fresh data. No spinner flash.
  • Background synchronization. Stale queries automatically refetch on window refocus, network reconnect, and component remount.
  • Smart retries. Failed queries retry three times with exponential backoff before surfacing an error.
  • Structural sharing. When refetched data is deeply equal to what you already had, you get the same object reference back, so React skips needless re-renders.
  • Mutations, pagination, infinite scroll, optimistic updates, prefetching, and SSR hydration, all first class.
  • Devtools that let you watch every query, its state, its data, and its timers in a floating panel.

One thing it deliberately does not do: the actual HTTP request. TanStack Query is a cache and async-state manager, not a fetch replacement. You bring your own fetch, axios, or ky, and Query wraps whatever promise-returning function you hand it. This trips up newcomers, so tattoo it on your forearm now.

Getting It Into Your Project

Install the React adapter with your package manager of choice.

npm install @tanstack/react-query
yarn add @tanstack/react-query

The devtools live in a separate package and are very much worth grabbing:

npm install @tanstack/react-query-devtools

TanStack Query v5 expects a modern toolchain: React 18 or newer, and TypeScript 4.7 or newer if you are using types.

Wrapping Your App in a Query Client

Everything flows through a single QueryClient, which holds the cache and your default configuration. You create it once and provide it near the root of your tree with QueryClientProvider.

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // treat data as fresh for one minute
    },
  },
});

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

Notice that staleTime of one minute. By default it is 0, which means data is considered stale the instant it arrives, so queries refetch aggressively. That default is the single most common source of "why is it refetching constantly?" confusion. Raising staleTime for data that does not change every second is the first tweak most teams reach for.

Reading Data with useQuery

A query is a declarative subscription to an async source, identified by a query key. You describe what you want, and the library handles the when and how.

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

interface Repo {
  name: string;
  description: string;
  stargazers_count: number;
}

function RepoCard() {
  const { isPending, error, data, isFetching } = useQuery({
    queryKey: ['repo', 'TanStack/query'],
    queryFn: async (): Promise<Repo> => {
      const res = await fetch(
        'https://api.github.com/repos/TanStack/query',
      );
      return res.json();
    },
  });

  if (isPending) return <p>Loading…</p>;
  if (error) return <p>Something broke: {error.message}</p>;

  return (
    <div>
      <h2>{data.name}</h2>
      <p>{data.description}</p>
      <strong>{data.stargazers_count}</strong>
      {isFetching ? ' (refreshing…)' : ''}
    </div>
  );
}

The v5 status model is worth understanding because it powers stale-while-revalidate. The status field is 'pending' | 'error' | 'success', while a separate fetchStatus axis ('fetching' | 'paused' | 'idle') describes the network request itself. These are independent on purpose: a query can be success (you have cached data) and isFetching (revalidating in the background) at the same time. That overlap is exactly why returning users see instant content instead of a spinner.

Query Keys Are Dependency Arrays

The query key is the heart of the cache. It is a serializable array that uniquely identifies and indexes your data, and the best mental model is to treat it like a useEffect dependency array: any variable used inside queryFn belongs in the key.

useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

useQuery({
  queryKey: ['todo', todoId],
  queryFn: () => fetchTodo(todoId),
});

useQuery({
  queryKey: ['todos', { status, page }],
  queryFn: () => fetchTodos({ status, page }),
});

When the key changes, the query refetches automatically, so you never wire up manual "refetch when todoId changes" logic. Object entries in a key are order-independent, while array entries are order-dependent. Keys also form a hierarchy used by fuzzy invalidation: invalidating ['todos'] matches ['todos', 1] and ['todos', { page: 2 }] alike. Because consistent keys are everything (a forgotten variable shows stale data, an inconsistent key causes duplicate fetches), a popular pattern is the query key factory, where you centralize key construction in one object to kill typos.

Writing Data with Mutations and Invalidation

Reads are only half the story. For create, update, and delete operations you reach for useMutation, and you pair it with invalidation to keep your reads fresh.

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

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

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

  return (
    <>
      {mutation.isPending && <p>Saving…</p>}
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      <button onClick={() => mutation.mutate('Walk the cat')}>
        Add Todo
      </button>
    </>
  );
}

invalidateQueries is the bridge between writes and reads. After changing server data, you mark the related queries stale. Any active query refetches immediately, while inactive ones refetch the next time they mount. This gives you a clean, declarative "write, then refresh" story instead of manually splicing the new todo into a dozen places. A mutation exposes mutate (fire-and-forget) and mutateAsync (returns a promise), along with isPending, isError, data, variables, and lifecycle callbacks onMutate, onSuccess, onError, and onSettled.

Optimistic Updates That Roll Back Gracefully

Waiting for a round trip before updating the UI feels sluggish. Optimistic updates let you apply the change immediately and roll back only if the server rejects it. The onMutate callback runs before the request and returns a context object you can use to restore the previous state.

const queryClient = useQueryClient();

useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old: Todo[]) => [
      ...old,
      newTodo,
    ]);
    return { previous };
  },
  onError: (_err, _newTodo, context) => {
    queryClient.setQueryData(['todos'], context?.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

The flow is: cancel any in-flight refetch so it cannot clobber your optimistic write, snapshot the current cache, apply the change, roll back from the snapshot on error, and finally invalidate so the cache reconciles with the truth from the server. For simpler cases, v5 added a lighter pattern where you read mutation.variables (or useMutationState across components) directly during render, avoiding manual cache surgery entirely.

Infinite Scroll Without the Headache

Endless lists and "load more" buttons are a classic pain point, and useInfiniteQuery makes them almost boring.

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

function ProjectFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/projects?cursor=${pageParam}`);
      return res.json();
    },
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  if (status === 'pending') return <p>Loading…</p>;
  if (status === 'error') return <p>Could not load projects.</p>;

  return (
    <>
      {data.pages.map((group, i) => (
        <div key={i}>
          {group.data.map((p) => (
            <p key={p.id}>{p.name}</p>
          ))}
        </div>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more…'
          : hasNextPage
            ? 'Load More'
            : 'That is everything'}
      </button>
    </>
  );
}

Your data arrives as data.pages, an array of page results, alongside data.pageParams. In v5 both initialPageParam and getNextPageParam are required, which forces the cursor logic to be explicit. There is also a maxPages option to cap memory for very long feeds, plus bidirectional fetching if you need to load earlier pages too.

What Changed in v5

Version 5 shipped in late 2023 and is the current line, now sitting at 5.100.x. If you are arriving from v4 or from an old tutorial, a few things moved.

  • A single unified object signature. Every hook takes one options object. All the old overloads are gone, which makes TypeScript errors clearer. A codemod helps you migrate.
  • loading became pending. The status value and the isLoading boolean were renamed to isPending. (isLoading still exists as the combination "pending and fetching" for the first load with no cache.)
  • cacheTime became gcTime. This garbage-collection time controls how long an inactive query stays cached before eviction, defaulting to five minutes. Do not confuse it with staleTime: staleTime decides when to refetch, gcTime decides when to forget. They answer different questions.
  • keepPreviousData moved into placeholderData. Use the exported helper: placeholderData: keepPreviousData to keep the previous page visible while the next loads.
  • Query callbacks removed. onSuccess, onError, and onSettled no longer exist on useQuery because they were unreliable with caching. They remain on mutations. Migrated v4 code that leaned on them will break.
  • First-class Suspense hooks like useSuspenseQuery, where data is guaranteed defined so you can drop the isPending checks. The bundle is also roughly 20 percent smaller than v4.

The Cat's Verdict

TanStack Query is not glamorous, and that is precisely the point. It quietly removes the most error-prone, repetitive code in your entire frontend and replaces it with a small, declarative surface: a key, a function that returns a promise, and the occasional invalidation. Once you internalize the server-state-versus-client-state framing, befriend the staleTime default, and treat your query keys like dependency arrays, you stop babysitting async state and start trusting it. With best-in-class devtools, one consistent mental model that travels across React, Vue, Svelte, Solid, and Angular, and a maintainer team that documents obsessively, it has more than earned its spot as the default for server state. Pour yourself something warm, delete a useEffect, and let the cache do its job.