Data Fetching
Data fetching in React: choose between useEffect (basic, error-prone), a query library (TanStack Query, SWR), a router loader (React Router, Next.js), or server components. Each comes with a different cache + retry + revalidation story. Pick by the problem, not by habit.
useEffect vs TanStack Query vs server components
EXAMPLE
// 1) Bare useEffect — works, but you'll re-invent half a library
import { useState, useEffect } from 'react';
function Orders() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError(null);
fetch('/api/orders', { signal: controller.signal })
.then((r) => r.json())
.then(setOrders)
.catch((e) => { if (e.name !== 'AbortError') setError(e); })
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{orders.map((o) => <li key={o.id}>{o.customer}</li>)}</ul>;
}
// Bugs in this pattern:
// - No cache; revisiting the page refetches everything
// - No deduplication of identical requests
// - No retry on transient errors
// - No revalidation on window focus / network reconnect
// - You write the abort + race-condition handling on every screen
// 2) TanStack Query — the production default in 2026
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const qc = new QueryClient({
defaultOptions: {
queries: { staleTime: 60_000, gcTime: 5 * 60_000, retry: 2 },
},
});
function Root({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
function OrdersList() {
const { data, isPending, error } = useQuery({
queryKey: ['orders'],
queryFn: async () => {
const res = await fetch('/api/orders');
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json() as Promise<any[]>;
},
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>Error: {(error as Error).message}</p>;
return <ul>{data!.map((o) => <li key={o.id}>{o.customer}</li>)}</ul>;
}
// 3) Mutation + cache invalidation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CancelButton({ id }: { id: string }) {
const qc = useQueryClient();
const cancel = useMutation({
mutationFn: () => fetch(\`/api/orders/${id}/cancel\`, { method: 'POST' }).then((r) => r.json()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['orders'] });
qc.invalidateQueries({ queryKey: ['orders', id] });
},
});
return <button disabled={cancel.isPending} onClick={() => cancel.mutate()}>Cancel</button>;
}
// 4) Query keys are stable, structured arrays
useQuery({ queryKey: ['orders', { status: 'open', page: 1 }], queryFn: ... });
// invalidating ['orders'] re-fetches every query starting with 'orders'.
// 5) Router loaders — React Router or Next.js
// React Router (see react/router-routes lesson):
// loader: async () => fetch('/api/orders').then((r) => r.json())
// Pros: declarative, removes a class of useEffect bugs
// Cons: tied to your router
// 6) React Server Components / Next.js App Router
// app/orders/page.tsx
// export default async function OrdersPage() {
// const orders = await db.orders.findMany();
// return <OrdersList orders={orders} />;
// }
// Data is fetched on the server; no client JS for the fetch.
// Pros: smallest client bundle, leverages your server
// Cons: only works in supported frameworks; mental model shift
// 7) Optimistic updates with TanStack Query
useMutation({
mutationFn: cancelOrder,
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ['orders'] });
const previous = qc.getQueryData(['orders']);
qc.setQueryData(['orders'], (old: any[]) =>
old.map((o) => o.id === id ? { ...o, status: 'cancelled' } : o));
return { previous };
},
onError: (_e, _id, ctx) => qc.setQueryData(['orders'], ctx?.previous),
onSettled: () => qc.invalidateQueries({ queryKey: ['orders'] }),
});
// 8) Patterns to internalise
// - Don't use bare useEffect for production data fetching after week one
// - Query keys describe WHAT, params describe FILTERS
// - Invalidate on mutate; do not manually setQueryData unless you mean it
// - staleTime = 'how long can I show this without refetching?'
// - gcTime = 'how long can I keep this in memory unused?'
// 9) Pitfalls
// - Forgetting AbortController in useEffect -> race conditions on fast navigation
// - useQuery in a useEffect (mostly an anti-pattern)
// - Using router loader AND useQuery for the same data -> double fetch
// - Server components with client-only data (cookies, headers) -> hydration mismatches
Why it matters
TanStack Query or a router loader removes the entire class of "I forgot the abort controller / race condition / stale state" bugs that bare `useEffect` ships with. Reach for a library on day one; the cost is one dependency, the benefit is cache, retry, revalidation, and abort handling for free.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
useEffect(() => {
fetch('/api/users').then(r => r.json()).then(setUsers);
}, []);
Try it Yourself »
Discussion
Loading…