Skip to content
React rc data 5 min read

TanStack Query (React Query)

Server state is fundamentally different from local UI state: it lives on a remote machine, can change without your app knowing, and is shared across components. TanStack Query (formerly React Query) treats this difference seriously, giving you caching, deduplication, background refetching, and stale-data management out of the box. Instead of hand-writing useState plus useEffect plus loading and error flags for every endpoint, you describe what data you need and the library handles when and how to keep it fresh.

Installing and setting up the provider

Install the package and wrap your app in a QueryClientProvider. The QueryClient holds the cache that every query and mutation reads from and writes to.

npm install @tanstack/react-query
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App.jsx";

const queryClient = new QueryClient();

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>
);

Reading data with useQuery

The core hook is useQuery. You give it a query key (a serializable array that uniquely identifies the data) and a query function that returns a promise. The hook returns the cached data plus status flags so you can render loading and error states declaratively.

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

async function fetchUsers() {
  const res = await fetch("https://jsonplaceholder.typicode.com/users");
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.json();
}

export default function UserList() {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
  });

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

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

Output:

Loading…        (first render)
Leanne Graham   (after the request resolves)
Ervin Howell
Clementine Bauch
...

If a second component also calls useQuery({ queryKey: ["users"] }) while the request is in flight, TanStack Query deduplicates it — only one network call is made and both components share the result.

Query keys and parameters

Query keys are how the cache is partitioned. When a key changes, TanStack Query treats it as a different query and fetches automatically. Include every variable that affects the result so each unique input gets its own cache entry.

function UserDetail({ userId }) {
  const { data, isPending } = useQuery({
    queryKey: ["users", userId],
    queryFn: () =>
      fetch(`https://jsonplaceholder.typicode.com/users/${userId}`).then((r) =>
        r.json()
      ),
    enabled: userId != null, // skip the query until we have an id
  });

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

The enabled option lets you create dependent queries — the fetch waits until its prerequisite data exists.

staleTime, gcTime, and background refetch

This is where the library shines. Every cached entry has two independent lifecycles:

OptionDefaultWhat it controls
staleTime0How long data is considered fresh. Fresh data is served from cache with no refetch.
gcTime5 * 60_000How long unused (no mounted observers) data stays in the cache before garbage collection.

When data is stale (older than staleTime), TanStack Query keeps showing it instantly from cache, then quietly refetches in the background and swaps in the fresh result. By default it also refetches on window refocus and network reconnect — so users returning to a tab see up-to-date data automatically.

useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
  staleTime: 60_000, // treat data as fresh for 1 minute — no refetch on remount
  gcTime: 10 * 60_000, // keep it cached 10 minutes after the last component unmounts
});

Tip: A staleTime of 0 (the default) means data is stale immediately, so revisiting a screen triggers a background refetch every time. Bumping staleTime is the single most effective way to cut redundant network traffic.

Invalidating queries

After a write, you usually want dependent queries to refetch. Rather than manually setting state, you invalidate the relevant keys and let the library re-run their query functions. invalidateQueries matches by key prefix, so invalidating ["users"] also refreshes ["users", 1], ["users", 2], and so on.

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

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

  const mutation = useMutation({
    mutationFn: (newUser) =>
      fetch("https://jsonplaceholder.typicode.com/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newUser),
      }).then((r) => r.json()),
    onSuccess: () => {
      // any query whose key starts with ["users"] becomes stale and refetches
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ name: "Ada Lovelace" })}>
      {mutation.isPending ? "Adding…" : "Add user"}
    </button>
  );
}

Why it removes most fetching boilerplate

Compare a hand-rolled effect to the useQuery version. The manual approach needs explicit state for data, loading, and error; cleanup to avoid race conditions; and offers no caching, deduplication, or background updates. TanStack Query collapses all of that into a single declarative call and adds capabilities you would rarely build yourself.

ConcernuseEffect + useStateTanStack Query
Loading / error stateManualBuilt in (isPending, isError)
Caching across componentsNoneAutomatic by query key
Request deduplicationNoneAutomatic
Background refetchManualOn focus, reconnect, interval
Stale-while-revalidateNonestaleTime driven
Cache cleanupNonegcTime garbage collection

Best Practices

  • Choose query keys that include every input affecting the result so cache entries stay distinct and invalidation is precise.
  • Set a sensible staleTime per query — short for fast-changing data, long for reference data — to avoid needless refetches.
  • Keep query functions pure and throwing: reject the promise on non-2xx responses so isError reflects reality.
  • Invalidate by key prefix after mutations instead of manually mutating cached state, unless you intentionally do optimistic updates.
  • Use enabled for dependent or conditional queries rather than calling hooks conditionally.
  • Add the React Query Devtools in development to inspect cache state, freshness, and in-flight fetches.
  • Treat the QueryClient as a singleton created once outside the component tree.
Last updated June 14, 2026
Was this helpful?