Skip to content
C Codeloom
React

TanStack Query (React Query) for Data Fetching

Learn how TanStack Query replaces useEffect-based data fetching with caching, background refetching, and request deduplication that scales to real apps.

·6 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • Why useEffect plus fetch falls apart at scale
  • How queries, mutations, and the cache fit together
  • How to configure stale time and refetch behavior
  • How to invalidate cache entries after writes
  • How to handle loading, error, and optimistic UI

Prerequisites

  • Comfortable with components and hooks — see What is React
  • Familiar with useState and useEffect — see React Hooks

Most React apps start with a tiny useEffect that calls fetch, sets some state, and renders the result. It works until the second page needs the same data, or the user navigates back, or the network drops, or two components ask for the same thing at the same time. TanStack Query (formerly React Query) replaces that pattern with a small library that treats server state as a cache, not as component state.

What problem it solves

Server state is fundamentally different from client state. It lives on a remote machine, it is shared across components, it goes stale, and it can fail in interesting ways. Plain useState plus useEffect treats it like local state, which leads to:

  • The same request firing from three components on the same screen
  • Stale data shown after a navigation because the fetch only ran on mount
  • Manual loading and error flags duplicated in every component
  • No way to refresh data when the window regains focus

TanStack Query centralizes all of that. A query is identified by a key, and any component that uses the same key gets the same cached value.

Install and set up the provider

npm install @tanstack/react-query

Wrap your app once with a QueryClientProvider:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      retry: 1,
    },
  },
});

export function App({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

staleTime is the most important knob. It is how long a query result is considered fresh. While fresh, components reading the same key get cached data instantly with no network call. After it expires, the next read triggers a background refetch.

Your first query

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

type User = { id: number; name: string };

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to load user');
  return res.json();
}

export function UserCard({ id }: { id: number }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  });

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

The queryKey is an array. Anything that influences the request belongs in it. If id changes, the key changes, and a new query runs. If two UserCard components mount with the same id, only one network request fires.

Mutations and cache invalidation

Reads use useQuery. Writes use useMutation. After a successful write, you usually want the affected queries to refetch.

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

export function RenameUser({ id }: { id: number }) {
  const qc = useQueryClient();

  const rename = useMutation({
    mutationFn: (name: string) =>
      fetch(`/api/users/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ name }),
      }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['user', id] });
    },
  });

  return (
    <button onClick={() => rename.mutate('Ada')}>
      Rename
    </button>
  );
}

invalidateQueries marks matching cache entries as stale. Any component currently rendering that key triggers a refetch automatically. You do not have to lift state, pass callbacks down, or wire up event buses.

Stale time vs cache time

Two timers shape behavior:

  • staleTime — how long data is considered fresh. Default is 0, which means every mount refetches in the background.
  • gcTime — how long unused data stays in memory after the last component unmounts. Default is 5 minutes.

For data that rarely changes (a user profile, a list of countries), set staleTime to several minutes. For data that changes constantly (a live dashboard), keep it short and lean on refetch on focus.

Dependent queries

Sometimes one query needs the output of another. Use the enabled flag.

const userQuery = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});

const projectsQuery = useQuery({
  queryKey: ['projects', userQuery.data?.id],
  queryFn: () => fetchProjects(userQuery.data!.id),
  enabled: !!userQuery.data,
});

The second query stays idle until the first resolves. No manual chaining of effects required.

Optimistic updates

For snappy UIs, update the cache before the server responds, then roll back on error.

const toggle = useMutation({
  mutationFn: toggleTodo,
  onMutate: async (todo) => {
    await qc.cancelQueries({ queryKey: ['todos'] });
    const prev = qc.getQueryData(['todos']);
    qc.setQueryData(['todos'], (old: any) =>
      old.map((t: any) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
    );
    return { prev };
  },
  onError: (_err, _todo, ctx) => {
    qc.setQueryData(['todos'], ctx?.prev);
  },
  onSettled: () => {
    qc.invalidateQueries({ queryKey: ['todos'] });
  },
});

The pattern is consistent: cancel inflight refetches, snapshot the previous value, apply the optimistic change, roll back on error, refetch on settle.

Devtools

The official devtools panel is the best part of the library. It shows every query, its state, its data, and its last fetch time. Install and mount it during development.

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// inside the provider tree
<ReactQueryDevtools initialIsOpen={false} />

When not to use it

TanStack Query is for server state. For purely local UI state (a modal toggle, a form draft), keep using useState or a small store. Mixing them is fine and expected. Routing-level data loaders (React Router, Next.js) can also reduce the need for client-side queries on initial render.

Wrap up

TanStack Query turns “fetch on mount, juggle flags” into “declare what you need by key, get caching for free.” Once you have a QueryClient, every component becomes a thin reader of shared server state. Add mutations and invalidation, and writes stay in sync without prop drilling. The library is not magic — it is a disciplined cache with sensible defaults — but those defaults remove most of the boilerplate that makes data fetching painful.