Data Fetching with TanStack Query
Reviewed & published by Brayan K
TanStack Query (formerly React Query) is the de-facto standard for server data in React. Instead of hand-rolling loading flags and caches in every useEffect, you describe what you want with a query key and a fetch function — and the library handles caching, deduplication, background refetching, retries, and stale data for you. This lesson shows the useQuery / useMutation workflow you'll use in real apps.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Why useEffect fetching gets painful at scale
- Set up QueryClientProvider once at the app root
- Read data with useQuery: queryKey + queryFn
- Branch on status: pending / error / success
- Cache by key, and what stale vs gc time mean
- Change data with useMutation + invalidateQueries
1️⃣ The Pain It Removes
A hand-written fetch needs three pieces of state, a cleanup guard against race conditions, and it re-fetches from scratch every mount with no shared cache:
// ❌ The manual way — repeated in every component, easy to get wrong.
function Todos() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
fetch("/api/todos")
.then(r => r.json())
.then(d => { if (active) { setData(d); setLoading(false); } })
.catch(e => { if (active) { setError(e); setLoading(false); } });
return () => { active = false; };
}, []);
// ...no caching, no dedupe, no background refresh, no retry
}TanStack Query gives you a tidy status machine instead of three booleans. The demo models those status transitions:
// useQuery hands you a small STATE MACHINE instead of raw flags.
// We model the same status transitions you'd read off the hook.
function runQuery(fetcher) {
// status starts "pending", then resolves to "success" or "error"
try {
const data = fetcher();
return { status: "success", data, error: null };
} catch (err) {
return { status: "error", data: undefined, error: err.message };
}
}
const ok = runQuery(() => ({ id: 1, name: "Ada" }));
const bad = runQuery(() => { throw new Error("404 Not Found"); });
console.log(ok); // { status: 'success', data: { id: 1, name: 'Ada' }, error: null }
console.log(bad); // { status: 'error', data: undefined, error: '404 Not Found' }
// In React you'd branch on status: pending -> spinner, error -> message,
// success -> render data. No manual loading/error booleans to juggle.2️⃣ Setup + Your First useQuery
Wrap your app once in a QueryClientProvider, then call useQuery anywhere:
// main.tsx — do this once
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
// Todos.tsx — anywhere in the app
import { useQuery } from "@tanstack/react-query";
function Todos() {
const { data, status, error } = useQuery({
queryKey: ["todos"], // cache identity
queryFn: () => fetch("/api/todos").then(r => r.json()), // how to fetch
});
if (status === "pending") return <p>Loading…</p>;
if (status === "error") return <p>Oops: {error.message}</p>;
return <ul>{data.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}The query key is the cache identity. Ask for the same key twice and you get one fetch, shared. Your turn — make the cache return the stored value instead of re-fetching:
// 🎯 YOUR TURN — fill in the ___ then press "Try it Yourself".
// TanStack Query caches results by KEY so the same key reuses one fetch.
// Model a tiny cache: only call the fetcher if the key isn't cached.
const cache = {};
let networkCalls = 0;
function getData(key, fetcher) {
// 1) If we've already cached this key, return it WITHOUT fetching.
if (key in cache) return ___; // 👉 return cache[key]
networkCalls++;
cache[key] = fetcher();
return cache[key];
}
getData("todos", () => ["buy milk"]);
getData("todos", () => ["buy milk"]); // served from cache, no fetch
getData("user-1", () => ({ name: "Linus" }));
console.log("network calls:", networkCalls);
// ✅ Expected output:
// network calls: 23️⃣ Dynamic Keys & Dependent Queries
Put variables in the key so each one caches separately. Use enabled to wait for prerequisites:
4️⃣ Changing Data with useMutation
Reads use useQuery; writes use useMutation. After a successful write, invalidate the affected queries so they refetch and the UI updates:
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddTodo() {
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: (text) =>
fetch("/api/todos", { method: "POST", body: JSON.stringify({ text }) }),
onSuccess: () => {
// mark ['todos'] stale -> it refetches and the list updates
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<button
disabled={addTodo.isPending}
onClick={() => addTodo.mutate("Walk the dog")}
>
{addTodo.isPending ? "Saving…" : "Add"}
</button>
);
}The demo models that invalidate-then-refetch cycle so you can see fresh data come back after a change:
// After changing data, you INVALIDATE related queries so they refetch.
// Model: a cache + an invalidate() that forces the next read to refetch.
const cache = { todos: ["buy milk"], _stale: false };
function readTodos(fetcher) {
if (cache._stale) { // stale -> refetch fresh data
cache.todos = fetcher();
cache._stale = false;
}
return cache.todos;
}
function addTodo(text, fetcher) {
// pretend we POSTed the new todo to the server...
cache._stale = true; // invalidateQueries(['todos'])
return readTodos(fetcher); // now it refetches
}
console.log(readTodos(() => ["buy milk"])); // ['buy milk'] (cached)
console.log(addTodo("walk dog", () => ["buy milk", "walk dog"]));
// ['buy milk', 'walk dog'] -> invalidation triggered a refetchReorder Challenge
These lines of a useQuery call are scrambled. Put them in the correct order:
A. queryKey: ["todos"],
B. const { data, status } = useQuery({
C. });
D. queryFn: () => fetch("/api/todos").then(r => r.json()),
E. staleTime: 60_000,Quick Recall
1) Two components both call useQuery({ queryKey: ["todos"] }). How many network requests fire?
One. The shared query key means both read the same cache entry and the request is deduped.
2) You just POSTed a new todo. What do you call so the list updates?
queryClient.invalidateQueries({ queryKey: ["todos"] }) in the mutation's onSuccess — it marks the query stale so it refetches.
3) What does enabled: false do on a query?
It pauses the query — it won't fetch until enabled becomes truthy. Great for dependent queries that need a value first.
Common Errors (and the fix)
- "No QueryClient set": you forgot to wrap the app in QueryClientProvider. Do it once at the root.
- queryFn doesn't throw on HTTP errors: fetch only rejects on network failure. Check res.ok and throw, or the query never enters the error state.
- Unstable query keys: a key with a fresh object/array each render busts the cache. Keep keys primitive and stable: ["user", id].
- Using useEffect to refetch after a mutation: don't — call invalidateQueries instead and let the library refetch.
📋 Quick Reference
| Task | Syntax |
|---|---|
| Read data | useQuery({ queryKey, queryFn }) |
| Dynamic key | queryKey: ["user", id] |
| Wait for deps | enabled: !!id |
| Write data | useMutation({ mutationFn }) |
| Refresh after write | invalidateQueries({ queryKey }) |
Mini-Challenge: Dedupe Counter
Model query deduplication. Three components request the same key; only the first should hit the network. Complete the cache so repeated keys are served without fetching.
// Three reads of the same key should result in ONE network call.
const cache = {};
let calls = 0;
function query(key, fetcher) {
if (key in cache) return cache[key];
calls++;
cache[key] = fetcher();
return cache[key];
}
query("profile", () => ({ name: "Ada" }));
query("profile", () => ({ name: "Ada" }));
query("profile", () => ({ name: "Ada" }));
// your code here: console.log("network calls:", calls)
// 🎯 Expected:
// network calls: 1🎉 Lesson Complete
- ✅ useQuery replaces manual loading/error/cache code
- ✅ The query key is the cache identity — same key, shared & deduped
- ✅ Branch on status: pending / error / success
- ✅ staleTime controls freshness; enabled gates dependent queries
- ✅ Write with useMutation, then invalidateQueries to refresh
Practice quiz
What does TanStack Query (formerly React Query) give you from a single useQuery call?
- Only a fetch wrapper
- A global state store like Redux
- Caching by key, request deduplication, status tracking, retries, and background refetching of stale data
- Server-side rendering
Answer: Caching by key, request deduplication, status tracking, retries, and background refetching of stale data. It replaces hand-written loading/error flags and caching with one hook that caches, dedupes, tracks status, retries, and refetches automatically.
What is a query key?
- An array that uniquely identifies a piece of server data and serves as the cache key
- A secret API token
- The HTTP method used
- The component's display name
Answer: An array that uniquely identifies a piece of server data and serves as the cache key. A query key like ['user', userId] identifies the cached data. Two components with the same key share one cached result and one request.
Two components both call useQuery({ queryKey: ['todos'] }). How many network requests fire?
- Two
- Zero
- One per render
- One — the request is deduped via the shared key
Answer: One — the request is deduped via the shared key. The shared query key means both read the same cache entry, so the request is deduplicated into a single fetch.
What does staleTime control?
- How long unused data stays cached before garbage collection
- How long fetched data is considered fresh, during which it won't refetch on mount or focus
- The request timeout
- How many retries happen
Answer: How long fetched data is considered fresh, during which it won't refetch on mount or focus. While data is within staleTime it's 'fresh' and won't refetch on remount/focus. The default is 0, meaning data is immediately stale.
What is gcTime (formerly cacheTime)?
- How long unused data remains in the cache after the last component using it unmounts, before being discarded
- How long data stays fresh
- The time before the first fetch
- The retry delay
Answer: How long unused data remains in the cache after the last component using it unmounts, before being discarded. gcTime is the garbage-collection window for inactive queries — distinct from staleTime, which governs freshness while data is in use.
When do you use useMutation instead of useQuery?
- For reading data (GET-like)
- Only for paginated reads
- For changing data (POST/PUT/DELETE-like)
- Never; useQuery handles writes too
Answer: For changing data (POST/PUT/DELETE-like). useQuery reads data; useMutation changes it. After a successful mutation you typically invalidate related queries so they refetch.
After a successful POST, how do you make the affected list update?
- Call useEffect to refetch manually
- queryClient.invalidateQueries({ queryKey: ['todos'] }), usually in the mutation's onSuccess
- Reload the whole page
- Mutate the cache object directly
Answer: queryClient.invalidateQueries({ queryKey: ['todos'] }), usually in the mutation's onSuccess. invalidateQueries marks matching queries stale so the library refetches them and the UI reflects the change — no manual useEffect needed.
What does enabled: false do on a query?
- Deletes the cached data
- Disables caching
- Forces an immediate fetch
- Pauses the query so it won't fetch until enabled becomes truthy
Answer: Pauses the query so it won't fetch until enabled becomes truthy. It gates the query — great for dependent queries, e.g. enabled: !!user?.teamId waits until the prerequisite value exists.
Why might a query never enter its error state on an HTTP 404?
- TanStack Query ignores 404s by design
- fetch only rejects on network failure, so you must check res.ok and throw in the queryFn
- 404 is treated as success data
- You forgot to set retries
Answer: fetch only rejects on network failure, so you must check res.ok and throw in the queryFn. fetch resolves on HTTP errors and only rejects on network failure. Check res.ok and throw so the queryFn signals an error.
What must wrap your app so useQuery works?
- BrowserRouter
- StrictMode
- QueryClientProvider with a QueryClient, set up once at the root
- Suspense
Answer: QueryClientProvider with a QueryClient, set up once at the root. Without a QueryClientProvider at the root you get a 'No QueryClient set' error. Create one QueryClient and provide it once.
Continue this course
- Previous: Checkpoint: Build with Hooks
- Next: React Router: Params, Nested Routes & Loaders — URL params with useParams, nested routes, Outlet, and data loaders
- Quick reference: React cheat sheet