React Query (TanStack Query) / SWR
React Query (TanStack Query) / SWR
Level 11 — Ecosystem Libraries Industry-standard data-fetching libraries that manage server state, caching, background refetching, and synchronization.
1. Prerequisites
- Side Effects — Asynchronous data fetching is a side effect managed declaratively by React Query.
useEffectHook — The manual hook pattern that React Query replaces for API interactions.
2. Term Category
Ecosystem (async state manager): React Query (part of TanStack Query) and SWR are specialized asynchronous server state management libraries for React. While client state managers (like Redux or Zustand) handle synchronous UI state owned by the browser application, React Query manages Server State—data hosted remotely on backend databases or microservices that can go out of date without client knowledge.
React Query replaces manual useEffect data-fetching logic by providing specialized custom hooks (useQuery, useMutation). It automatically manages cache indexing, loading/error states, duplicate request deduplication, background refetching upon window refocus, retries, optimistic updates, and garbage collection.
3. Explanation
(1) Design Motivation — "Why did we design this?"
In traditional Client-Side Rendered React applications, fetching API data required writing repetitive, error-prone imperative boilerplate inside every component:
// Legacy useEffect + useState data fetching pattern
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
fetch('/api/users')
.then(res => res.json())
.then(resData => {
if (isMounted) {
setData(resData);
setIsLoading(false);
}
})
.catch(err => {
if (isMounted) {
setError(err);
setIsLoading(false);
}
});
return () => { isMounted = false; }; // Race condition handling
}, []);
This manual approach introduces severe production issues:
- No Shared Cache: If two components read the same user data, both fire separate redundant network requests.
- Stale Data: Data downloaded into local component state stays static; if another user updates the database, the screen displays stale data indefinitely.
- Complex Boilerplate: Every single data component requires 20+ lines of identical state setup.
React Query completely eliminates this boilerplate. Utilizing a Stale-While-Revalidate (SWR) caching strategy, useQuery immediately serves cached data to the screen (zero loading spinner latency) while background-refetching fresh data from the server and updating the UI seamlessly.
(2) Reality Metaphor
Imagine a library reference desk.
- Manual
useEffect(Hiring an Investigator Every Visit): Every time you enter a room and want to check a book's availability, you hire a research assistant (writeuseEffect&fetch). The assistant puts on a coat, walks across town to the central library (network request), searches the stacks, and walks back 15 minutes later (high latency). If you walk into another room and ask the same question, a second assistant is dispatched to walk across town again (duplicate fetch). - React Query (Local Desktop Cache with Radio Wire): You walk into the room. A local reference ledger sits on your desk (global React Query cache). You open the ledger and read the answer instantly (sub-millisecond cache read). Meanwhile, a silent radio operator on the desk calls the central library in the background (background refetch). If the library reports a book was checked out, the operator updates your ledger, and your display refreshes silently.
(3) React Code Examples
Short Snippet
// UserList.jsx (TanStack React Query v5)
import { useQuery } from '@tanstack/react-query';
export function UserList() {
// useQuery handles loading, error, caching, and background refetching automatically
const { data: users, isLoading, isError, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});
if (isLoading) return <div className="spinner">Loading users...</div>;
if (isError) return <div className="error">Error: {error.message}</div>;
return (
<ul className="user-list">
{users.map(user => (
<li key={user.id}>{user.name} ({user.email})</li>
))}
</ul>
);
}
Fuller Example
// PatientVitalsTracker.jsx
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
async function fetchPatientVitals(patientId) {
const res = await fetch(`/api/patients/${patientId}/vitals`);
if (!res.ok) throw new Error('Failed to fetch patient vitals');
return res.json();
}
async function updateVitalsBaseline({ patientId, newHr }) {
const res = await fetch(`/api/patients/${patientId}/vitals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ heartRate: newHr })
});
if (!res.ok) throw new Error('Failed to update vitals');
return res.json();
}
export function PatientVitalsTracker({ patientId }) {
const queryClient = useQueryClient();
// 1. Fetch server state with unique query key
const { data: vitals, isLoading, isError } = useQuery({
queryKey: ['patient-vitals', patientId],
queryFn: () => fetchPatientVitals(patientId),
staleTime: 10000 // Data remains fresh for 10 seconds before background refetch
});
// 2. Mutation handler for updating server state
const mutation = useMutation({
mutationFn: updateVitalsBaseline,
onSuccess: () => {
// Invalidate query cache to trigger automatic refetching
queryClient.invalidateQueries({ queryKey: ['patient-vitals', patientId] });
}
});
const handleUpdate = () => {
mutation.mutate({ patientId, newHr: 72 });
};
if (isLoading) return <p>Loading patient telemetry...</p>;
if (isError) return <p>Telemetry stream offline.</p>;
return (
<div className="vitals-card">
<h3>Patient #{patientId} Vitals</h3>
<p>Heart Rate: {vitals.heartRate} BPM</p>
<p>Blood Pressure: {vitals.bpSys}/{vitals.bpDia} mmHg</p>
<button
onClick={handleUpdate}
disabled={mutation.isPending}
className="btn-update"
>
{mutation.isPending ? 'Syncing...' : 'Reset HR Baseline (72 BPM)'}
</button>
</div>
);
}
4. Common Mistakes & Pitfalls
Mistake 1: Storing server API data in global client state stores (Redux or Zustand)
The mistake: Fetching API data and immediately saving the array response into a global Redux or Zustand store.
Why it's wrong: API data is Server State (remotely owned data subject to staleness). Redux/Zustand are designed for Client State (locally owned UI state like modal visibility or dark mode). Storing API data in Redux forces developers to manually re-implement caching, loading, refetching, and normalization logic.
Incorrect:
// ❌ Anti-pattern: Storing server API data manually inside global Redux!
useEffect(() => {
fetch('/api/users').then(res => res.json()).then(data => {
dispatch(setGlobalUsers(data));
});
}, []);
Fix:
// Let React Query handle server state caching automatically
const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
Mistake 2: Using non-unique query keys for parameterized queries
The mistake: Using a static queryKey: ['user'] for queries that accept dynamic parameters (e.g. userId).
Why it's wrong: React Query indexes its global cache based on queryKey. Omitting reactive variables (like userId) causes all users to share the exact same cached response object.
Incorrect:
// ❌ Static key: user #1 and user #2 will return identical cached data!
useQuery({ queryKey: ['user'], queryFn: () => fetchUser(userId) });
Fix:
// Include reactive parameters in the queryKey array
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
Mistake 3: Forgetting to invalidate queries via queryClient.invalidateQueries() after mutations
The mistake: Executing a useMutation write operation (e.g., adding a new item) without invalidating the relevant query key cache.
Why it's wrong: After a mutation succeeds on the backend database, React Query's cached list remains stale until explicitly invalidated or refetched.
Incorrect:
// ❌ Cache remains stale after mutation succeeds!
const mutation = useMutation({ mutationFn: createTodo });
Fix:
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// Purge stale cache and refetch fresh list automatically
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
5. Practice Exercises
Exercise 1: IoT Turbine Telemetry Query with Refetch Interval
Scenario: Develop an IoT telemetry dashboard component that queries live turbine metrics using React Query, polling the backend API automatically every 5 seconds.
Requirements:
- Use
useQuerywithqueryKey: ['turbine-telemetry', turbineId]. - Configure
refetchInterval: 5000(5-second polling). - Render live RPM and temperature metrics.
Answer
Implementation
'use client';
import { useQuery } from '@tanstack/react-query';
async function fetchTurbineMetrics(turbineId) {
const res = await fetch(`/api/iot/turbines/${turbineId}`);
if (!res.ok) throw new Error('Telemetry fetch failed');
return res.json();
}
export function TurbineTelemetryWidget({ turbineId = 't-401' }) {
const { data: metrics, isLoading, isError, isFetching } = useQuery({
queryKey: ['turbine-telemetry', turbineId],
queryFn: () => fetchTurbineMetrics(turbineId),
refetchInterval: 5000 // Polling every 5 seconds
});
if (isLoading) return <p>Connecting to turbine sensors...</p>;
if (isError) return <p>Error: Telemetry stream unreachable.</p>;
return (
<div className="telemetry-card">
<header className="card-header">
<h3>Turbine #{turbineId} Telemetry</h3>
{isFetching && <span className="refetch-pill">Syncing...</span>}
</header>
<div className="metrics-body">
<p>Rotational Speed: <strong>{metrics.rpm} RPM</strong></p>
<p>Core Temp: <strong>{metrics.temp}°C</strong></p>
<p>Vibration Index: <strong>{metrics.vibration} mm/s</strong></p>
</div>
</div>
);
}
Technical Explanation
- Automatic Polling:
refetchInterval: 5000configures background polling without writing manualsetIntervalhooks. - Background Indicator:
isFetchingindicates active background refetching while preserving existingdataon screen. - Unique Cache Index:
queryKey: ['turbine-telemetry', turbineId]isolates cache entries per turbine ID. - Declarative Error States: Handles loading and error statuses cleanly via destructuring.
Exercise 2: Financial Order Cancellation Mutation
Scenario: Build a Financial Trading open orders panel where clicking "Cancel Order" triggers a useMutation call, invalidating the ['open-orders'] query cache upon completion.
Requirements:
- Implement
useQueryfetching open orders. - Implement
useMutationexecuting order cancellation POST request. - Invalidate
['open-orders']insideonSuccesscallback.
Answer
Implementation
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
async function fetchOpenOrders() {
const res = await fetch('/api/trading/orders/open');
return res.json();
}
async function cancelOrderApi(orderId) {
const res = await fetch(`/api/trading/orders/${orderId}/cancel`, { method: 'POST' });
return res.json();
}
export function OpenOrdersDesk() {
const queryClient = useQueryClient();
const { data: orders, isLoading } = useQuery({
queryKey: ['open-orders'],
queryFn: fetchOpenOrders
});
const cancelMutation = useMutation({
mutationFn: cancelOrderApi,
onSuccess: () => {
// Invalidate open orders cache to force immediate refetch
queryClient.invalidateQueries({ queryKey: ['open-orders'] });
}
});
if (isLoading) return <p>Loading active order desk...</p>;
return (
<div className="orders-desk">
<h3>Active Open Orders</h3>
<ul>
{orders.map(order => (
<li key={order.id} className="order-row">
<span>{order.symbol} ({order.qty} @ ${order.price})</span>
<button
onClick={() => cancelMutation.mutate(order.id)}
disabled={cancelMutation.isPending}
className="btn-cancel"
>
{cancelMutation.isPending ? 'Canceling...' : 'Cancel Order'}
</button>
</li>
))}
</ul>
</div>
);
}
Technical Explanation
- Cache Invalidation:
invalidateQueries({ queryKey: ['open-orders'] })forces React Query to purge stale orders and refetch fresh data. - Mutation State:
cancelMutation.isPendingdisables buttons during active network cancellation requests. - Declarative Mutation Execution:
cancelMutation.mutate(order.id)triggers mutation execution cleanly on click. - Decoupled Server State: Order desk data remains synchronized without manual array splicing in client state.
Exercise 3: E-Commerce Shopping Cart Optimistic Update
Scenario: Implement an e-commerce shopping cart item quantity updater using React Query's onMutate optimistic update callback to modify cart cache before network completion.
Requirements:
- Configure
useMutationwithonMutatecallback. - Update cache optimistically via
queryClient.setQueryData(). - Roll back cache context if network request fails inside
onError.
Answer
Implementation
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function updateItemQtyApi({ itemId, quantity }) {
const res = await fetch(`/api/cart/items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity })
});
if (!res.ok) throw new Error('Update failed');
return res.json();
}
export function OptimisticCartItem({ item }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateItemQtyApi,
onMutate: async (newItem) => {
// 1. Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['cart'] });
// 2. Snapshot previous value
const previousCart = queryClient.getQueryData(['cart']);
// 3. Optimistically update cache
queryClient.setQueryData(['cart'], old =>
old ? old.map(i => i.id === newItem.itemId ? { ...i, quantity: newItem.quantity } : i) : []
);
return { previousCart };
},
onError: (err, newItem, context) => {
// 4. Rollback to snapshot on error
if (context?.previousCart) {
queryClient.setQueryData(['cart'], context.previousCart);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['cart'] });
}
});
return (
<div className="cart-item-row">
<span>{item.name}</span>
<button onClick={() => mutation.mutate({ itemId: item.id, quantity: item.quantity + 1 })}>
+1 (Optimistic)
</button>
</div>
);
}
Technical Explanation
- Optimistic Cache Mutation:
setQueryData()updates local cache immediately before network round-trip completes. - Rollback Snapshot:
context.previousCartcaptures pre-mutation state to revert UI safely if network fails. - Refetch Synchronization:
onSettledinvalidates queries to guarantee server-client consistency. - Sub-Millisecond UI Response: User views quantity updates instantly without waiting for server response latency.
6. Related Terms
useEffectHook — The manual effect hook replaced by React Query.- Side Effects — Async network requests managed declaratively.
- Redux — Client state container distinct from server state management.
- Zustand — Lightweight client state manager.
7. Key Takeaways
- React Query and SWR manage asynchronous Server State, caching, loading, and refetching automatically.
- Replaces manual
useState+useEffectfetching boilerplate with singleuseQuerycalls. - Leverages Stale-While-Revalidate (SWR) caching to serve instant cached UI while refetching in the background.
- Include all reactive parameters in
queryKeyarrays to ensure unique cache indexing. - Use
useMutationfor write operations, invalidating queries viainvalidateQueries()on success. - Never store API server data inside client state managers like Redux or Zustand.