π What You Will Learn
- Why hand-rolled useEffect fetching hurts: loading flags, race conditions, zero caching
- useQuery: queryKey + queryFn, and the
isPending/isError/datastates - Query keys as cache identity: parameterized queries like
['user', userId] - Retries done right: automatic exponential backoff + honest loading UI
- useMutation: POST/PUT/DELETE with
invalidateQueriesto keep lists fresh - Optimistic updates: the
onMutatesnapshot + rollback pattern - staleTime vs gcTime: the mental model that makes the cache predictable
- When to skip it: server components, tiny apps, and where SWR fits
Prerequisites: you should be comfortable with useEffect and custom hooks β this post builds directly on both.
The Problem with Hand-Rolled useEffect Fetching
Every React dev writes this component at some point. You need a user profile from the API, so you reach for useEffect + fetch, add a loading flag, add an error state⦠and before you know it, your "simple fetch" looks like this:
import { useState, useEffect } from 'react';
export function UserProfile({ userId }) {
// Three pieces of state just to track ONE request
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Guard flag: if userId changes mid-request, the OLD response
// must not overwrite the NEW one (the classic race condition)
let ignore = false;
const controller = new AbortController();
setIsLoading(true);
setError(null);
async function load() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Request failed with status ${res.status}`);
}
const data = await res.json();
if (!ignore) {
setUser(data);
}
} catch (err) {
// AbortError is not a real error β we cancelled it ourselves
if (err.name !== 'AbortError' && !ignore) {
setError(err);
}
} finally {
if (!ignore) {
setIsLoading(false);
}
}
}
load();
// Cleanup: runs when userId changes or the component unmounts
return () => {
ignore = true;
controller.abort();
};
}, [userId]); // refetch whenever userId changes
if (isLoading) return <p>Loadingβ¦</p>;
if (error) return <p>Error: {error.message}</p>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.role}</p>
</div>
);
}Almost 60 lines β and honestly, this is the careful version. Most tutorials skip the ignore flag and the AbortController, which means most real apps ship the race condition. And even this careful version still has real problems:
- No cache. Navigate away and back? Full loading spinner again, even for data you fetched 5 seconds ago.
- No deduplication. Two components need the same user? Two identical requests hit your API.
- No retries. One flaky network blip = error screen. The user has to refresh manually.
- No background refresh. Data silently goes stale while the tab sits open.
- Copy-pasted everywhere. Every fetching component repeats this same 60-line dance, with slightly different bugs.
ignore flag: user clicks profile A (slow response), then profile B (fast response). B rendersβ¦ then A's late response arrives and overwrites B. The user is looking at the wrong person's profile. This bug ships to production constantly because it only appears on slow networks.Ang totoo niyan: fetching data is not the hard part. Managing server state over time β caching it, syncing it, invalidating it β is the hard part. That's exactly the job TanStack Query was built for.
What TanStack Query Gives You
TanStack Query (formerly React Query) is a server-state manager. It treats data from your API as a cache that needs syncing β not as ordinary component state. You describe what data you need and how to get it; the library handles everything else:
| Concern | Hand-rolled useEffect | TanStack Query |
|---|---|---|
| Loading / error state | 3 useState per request, everywhere | Built-in: isPending, isError, data |
| Race conditions | Manual ignore flag + AbortController | Handled automatically per query key |
| Caching | None β refetch on every mount | Automatic, keyed cache with instant replays |
| Request deduplication | None β N components = N requests | N components, 1 request, shared result |
| Retries | Write your own retry loop | Automatic with exponential backoff |
| Background refetch | Write your own polling/focus logic | On window focus, reconnect, interval β built in |
| Devtools | console.log | Dedicated devtools panel showing every query's state |
useState + useEffect is for client state (form inputs, open modals, toggles). Data that lives on a server is server state β you don't own it, it can change without you, and multiple components need the same copy. Different problem, different tool. Once this clicks, you'll stop fighting useEffect for fetching entirely.Install & Setup: QueryClient + QueryClientProvider
Setup is two steps: install the package, then wrap your app in a provider β the same pattern you already know from useContext.
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtoolsimport React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
// One client for the whole app β it OWNS the cache.
// Create it OUTSIDE the component so it isn't recreated on re-render.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // data is "fresh" for 1 minute (more in section 10)
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* Devtools only render in development builds */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);QueryClientProvider uses context, so it must live in a "use client" component. Make a small Providers.tsx client component that creates the client with useState(() => new QueryClient()) (so each request gets its own client during SSR) and wrap it around children in your root layout.Your First useQuery
A query needs exactly two things: a queryKey (the cache's name for this data) and a queryFn (any async function that returns the data or throws). In return you get the full request lifecycle as plain values β no useState, no useEffect, no ignore flags.
Try it below. Mount the component and watch isPending do its thing. Then unmount and mount again β instant data, because the cache already has it, while a background refetch quietly updates it. One heads-up: since @tanstack/react-query isn't installed in this article's codebase, the demo simulates the library's behavior with plain React (an in-memory Map as the cache, setTimeout as the network). The Code tab shows the real TanStack Query code you'd write.
Component is unmounted. Mount it to fire the query. Then unmount and mount again β the second time, the list appears instantly from the cache while a background refetch runs. (This demo simulates the cache with an in-memory Map so it can run inside this article.)
isPending means "no data yet at all" (first load β show a spinner). isFetching means "a request is in flight" β including background refetches where you already have data on screen. Show a subtle indicator for isFetching, never a full-page spinner. (In v4 this was called isLoading; v5 renamed it to isPending.)Query Keys Are Cache Identity (and Dependencies)
The queryKey array is the single most important concept in the whole library. It plays two roles at once:
- Cache identity:
['user', 1]and['user', 2]are two separate cache entries. Same key anywhere in the app = same shared data, one request. - Dependency array: when any value inside the key changes, TanStack Query automatically fetches the new key β like a
useEffectdependency array, but with caching built in.
Rule of thumb: everything your queryFn uses must be in the key. Fetching page 3 of search results for "shoes"? The key is ['products', { search: 'shoes', page: 3 }]. Try the demo β pick a user, then come back to a user you already visited (again, cache simulated in-article; real code in the Code tab):
Pick a user. First visit = loading spinner. Visit the same user again = instant, straight from the cache β that's the query key doing its job. (Cache simulated with a Map for this article.)
query cache contents:
(empty)
Loading, Error & Retry States Done Right
When a queryFn throws, TanStack Query doesn't give up immediately β it retries (3 times by default) with exponential backoff before setting isError. That means one flaky network blip usually never reaches your users at all.
The demo below simulates the retry flow. Leave the failure toggle on and watch the backoff (1s, then 2s). Or β my favorite trick β start a fetch, then uncheck the toggle mid-retry to "fix the server" and watch a retry quietly succeed, exactly like a real transient outage:
(request log β click "Fetch data")
404 or 403 will fail all 4 attempts and just make your user wait longer for the same error. In production, make retry conditional: retry: (count, error) => error.status >= 500 && count < 3.Mutations with useMutation
Queries read; mutations write (POST, PUT, DELETE). The magic is what happens after the write: invalidateQueries marks the related cached data as stale, and TanStack Query refetches it automatically. Your todo list updates without you ever manually syncing state.
Add a todo below and read the log: the POST fires, then invalidateQueries(['todos']), then the list refetches itself. (Server + cache simulated in plain React so it runs here.)
β³ Loading todosβ¦
(mutation log β add a todo and watch the invalidate β refetch flow)
The flow to internalize β this is the pattern you'll use for 90% of writes:
mutate(newTodo)
β POST /api/todos (mutationFn runs)
β onSuccess fires
β invalidateQueries({ queryKey: ['todos'] })
β every ['todos'] query is marked STALE
β active ones refetch automatically
β every component using useQuery(['todos']) re-renders with fresh dataid, timestamps, computed fields, or apply validation you don't know about. Refetching guarantees your UI matches reality. Manually patching the cache (via setQueryData) is an optimization β which brings us to optimistic updates.Optimistic Updates (onMutate + Rollback)
For interactions that should feel instant β likes, toggles, drag reorders β waiting even 300ms for the server feels laggy. An optimistic update flips the order: update the UI first, send the request, and roll back only if the server says no. Users get instant feedback; the rare failure snaps back gracefully.
Click the like button β the count jumps before the "network" responds. Then turn on the failure toggle and watch the rollback:
Notice the count jumps before the fake network finishes. With the failure toggle on, it snaps back β that's the onMutate snapshot + onError rollback flow, simulated here in plain React.
The three-step recipe in the Code tab is always the same:
onMutate: cancel in-flight refetches, snapshot the current cache, apply the optimistic value, return the snapshot as contextonError: restore the snapshot from context β the rollbackonSettled: invalidate, so the cache re-syncs with the server either way
cancelQueries in onMutate. If a background refetch is already in flight when you apply your optimistic value, its response can land a moment later and overwrite your update with stale data. Cancelling first closes that race window.Pagination & Infinite Queries
Since the page number lives in the query key, pagination is almost free: ['products', page] gives each page its own cache entry. The one UX problem: clicking "Next" on an uncached page normally blanks the list to a spinner. Fix it with placeholderData: keepPreviousData β the old page stays on screen (flagged by isPlaceholderData) until the new one arrives:
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useState } from 'react';
async function fetchProducts(page) {
const res = await fetch(`/api/products?page=${page}&limit=10`);
return res.json(); // { items: [...], hasMore: true }
}
export function ProductList() {
const [page, setPage] = useState(1);
const { data, isPending, isPlaceholderData } = useQuery({
queryKey: ['products', page], // page is part of the key
queryFn: () => fetchProducts(page),
// v5: keepPreviousData is a helper you pass to placeholderData.
// (The old v4 boolean option `keepPreviousData: true` is gone.)
placeholderData: keepPreviousData,
});
if (isPending) return <p>Loading productsβ¦</p>;
return (
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
<ul>
{data.items.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
<button onClick={() => setPage((p) => Math.max(p - 1, 1))} disabled={page === 1}>
Previous
</button>
<span> Page {page} </span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button>
</div>
);
}For infinite scroll / "Load more" feeds, switch to useInfiniteQuery β it accumulates pages into data.pages and tracks the cursor for you:
import { useInfiniteQuery } from '@tanstack/react-query';
export function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) =>
fetch(`/api/feed?cursor=${pageParam}`).then((res) => res.json()),
initialPageParam: 0, // required in v5
// Return the next cursor, or undefined when there are no more pages
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
return (
<div>
{data?.pages.map((page) =>
page.items.map((post) => <PostCard key={post.id} post={post} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading moreβ¦'
: hasNextPage
? 'Load more'
: 'No more posts'}
</button>
</div>
);
}staleTime vs gcTime β The Mental Model
These two settings confuse everyone, pero simple lang talaga once you see that cached data moves through three states:
fetched ββ[staleTime elapses]βββΆ stale ββ[unused + gcTime elapses]βββΆ garbage-collected
FRESH : "this data is good" β components use it, NO refetch happens
STALE : "usable but maybe outdated" β shown instantly, refetched in background
GC'd : removed from memory β next use is a full loading-spinner fetch| Setting | Question it answers | Default | Effect |
|---|---|---|---|
staleTime | How long is data considered fresh? | 0 (stale immediately) | Fresh data is served from cache with NO refetch at all |
gcTime | How long does unused data stay in memory? | 5 minutes | After this, the cache entry is deleted β next mount shows isPending again |
With the default staleTime: 0, every mount and every window focus triggers a background refetch. That's safe but chatty. Tune it per query based on how often the data actually changes:
// Country list β changes basically never
useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // never refetch automatically
});
// Product catalog β changes a few times a day
useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
staleTime: 5 * 60 * 1000, // fresh for 5 minutes
});
// Notifications β should feel live
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 0, // always stale β refetch on focus/mount
refetchInterval: 30 * 1000, // and poll every 30s
});gcTime was called cacheTime in v4. The rename is honest β it never controlled "how long things are cached", it controls when unused entries get garbage-collected. Remembering "staleTime = freshness, gcTime = memory" will save you from 90% of cache-tuning confusion.When You DON'T Need TanStack Query
Honest talk: hindi ito kailangan ng lahat ng app. TanStack Query shines when the client owns interactive server state. Skip it when:
- You're in a React Server Component. A one-shot read that renders on the server needs no client cache at all β just
awaitthe fetch:
// No "use client", no useQuery, no loading state to manage.
// The server fetches, renders HTML, and Next.js handles caching.
export default async function UsersPage() {
const res = await fetch('https://api.example.com/users', {
next: { revalidate: 60 }, // Next.js-level caching
});
const users = await res.json();
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}- Tiny apps with 1β2 fetches that load once and never change β a well-written
useEffect(or a small custom hook like the one from the custom hooks post) is fine. Adding a library to fetch one config file is over-engineering. - You prefer a smaller API:
SWR(by Vercel) covers the same core ideas β cache keys, revalidate on focus, dedupe β with fewer features and a lighter footprint. If your app is mostly reads with simple writes, SWR is a legit choice. TanStack Query pulls ahead on mutations, optimistic updates, infinite queries, and devtools.
useEffect, walang arte.Common Mistakes
Date, a non-memoized computed object) makes every render look like a brand-new query: infinite refetch loop, cache never hit.// β BAD: new Date() changes every render β new key β refetch forever
useQuery({
queryKey: ['report', new Date()],
queryFn: fetchReport,
});
// β
GOOD: derive a stable value first
const today = new Date().toISOString().slice(0, 10); // "2026-07-18"
useQuery({
queryKey: ['report', today],
queryFn: () => fetchReport(today),
});// β BAD: fetching in useEffect and pushing into TanStack Query manually β
// you get NONE of the dedupe/retry/race protection
useEffect(() => {
fetch('/api/users')
.then((res) => res.json())
.then((data) => queryClient.setQueryData(['users'], data));
}, [queryClient]);
// β
GOOD: let the library own the fetch
useQuery({ queryKey: ['users'], queryFn: fetchUsers });['orders', user.id] when user is still undefined sends /api/orders/undefined to your backend. Gate it with enabled β the query simply waits.const { data: user } = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
});
// β
This query won't run until user?.id actually exists
const { data: orders } = useQuery({
queryKey: ['orders', user?.id],
queryFn: () => fetchOrders(user.id),
enabled: !!user?.id,
});queryClient.invalidateQueries() with no arguments nukes the entire cache β every query in the app refetches after every mutation. On a dashboard with 15 queries, that's a 15-request stampede because someone renamed a todo. Always scope the invalidation to the keys the mutation actually affected: invalidateQueries({ queryKey: ['todos'] }).Best Practices Checklist
useUsers(), useTodos()) so keys and fetchers live in one place, not scattered across componentsstaleTime (30β60s) on the QueryClient, then tune per queryenabled for dependent queries instead of conditional hook calls (Rules of Hooks!)| API | What it's for | Returns / key fields |
|---|---|---|
useQuery | Read data with caching | data, isPending, isError, isFetching, refetch |
useMutation | Create / update / delete | mutate, isPending, onSuccess / onError |
useInfiniteQuery | Infinite scroll / load-more | data.pages, fetchNextPage, hasNextPage |
useQueryClient | Access the cache imperatively | invalidateQueries, setQueryData, cancelQueries |
staleTime | How long data counts as fresh | 0 by default β fresh data never refetches |
gcTime | When unused cache entries are dropped | 5 min by default (was cacheTime in v4) |
Practice Project + What's Next
Cement all of this by building a GitHub repo explorer (the GitHub API is free, no key needed for light use):
- Search input β
useQuery({ queryKey: ['repos', searchTerm], ... })hittinghttps://api.github.com/search/repositories?q=β¦ - Click a repo β detail view with
['repo', owner, name]β verify revisits are instant from cache - Paginate results with
placeholderData: keepPreviousDataβ no spinner flash between pages - Add a client-side "favorite" toggle with an optimistic update (fake the server with a delay, like our demos did)
- Open the React Query Devtools and watch each query go fresh β stale β inactive β garbage-collected
If you can explain to a friend why the detail view is instant the second time β sa cache galing, hindi sa network β you've got it.
- useEffect Deep Dive β everything TanStack Query replaced still matters for non-fetch side effects (subscriptions, timers, DOM APIs)
- Custom Hooks β the pattern behind
useUsers()/useTodos()wrappers that keep your query logic organized - State Management β TanStack Query owns server state; learn where Context, Zustand, and Redux fit for client state
Keep practicing! πͺ The day TanStack Query really clicked for me was the day I stopped thinking "how do I fetch this?" and started thinking "what is this data's name, and when does it go stale?" Once server state has proper names, whole categories of bugs β race conditions, stale screens, duplicate requests β just stop existing in your codebase.