Data Fetching Patterns
Modern data fetching in React — from raw useEffect to TanStack Query, SWR, Suspense, and Server Components.
Data Fetching Patterns
Data fetching is one of the most common tasks in React applications, and the ecosystem has evolved significantly. This guide covers the spectrum from manual fetching with useEffect to production-ready libraries like TanStack Query and SWR, plus modern patterns like Suspense and Server Components.
⚠️ The Evolution
Raw useEffect + fetch is fine for learning, but production apps should use a data fetching library. These handle caching, background refetching, error retries, race conditions, and more — problems that are surprisingly hard to solve correctly by hand.
1. The Basic Pattern: useEffect + fetch
The starting point. You need to handle loading, errors, race conditions, and cleanup yourself.
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUser();
// Cleanup: abort on unmount or userId change
return () => controller.abort();
}, [userId]);
if (loading) return <Skeleton />;
if (error) return <ErrorMessage message={error} />;
return <div>{user.name}</div>;
}
Issues with Manual Fetching
- • No caching — refetches on every mount, even if data hasn't changed
- • No background refetching — stale data stays stale
- • No deduplication — multiple components fetching the same data
- • Boilerplate — loading/error/data state repeated in every component
- • No retry logic — one failure and you're done
2. Building a Reusable Hook
Encapsulate the fetch pattern in a custom hook to reduce boilerplate.
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, loading };
}
// Usage
function Posts() {
const { data: posts, error, loading } = useFetch("/api/posts");
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return posts.map((p) => <h3 key={p.id}>{p.title}</h3>);
}
3. TanStack Query (React Query)
The most popular data fetching library for React. It handles caching, background refetching, pagination, optimistic updates, and much more.
import { QueryClient, QueryClientProvider, useQuery, useMutation } from "@tanstack/react-query";
const queryClient = new QueryClient();
// Wrap your app
function App() {
return (
<QueryClientProvider client={queryClient}>
<Posts />
</QueryClientProvider>
);
}
// Fetching data
function Posts() {
const {
data: posts,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ["posts"], // Cache key
queryFn: () => // Fetcher function
fetch("/api/posts").then((r) => r.json()),
staleTime: 5 * 60 * 1000, // 5 min before data is "stale"
gcTime: 30 * 60 * 1000, // 30 min before garbage collection
retry: 3, // Retry failed requests
});
if (isLoading) return <Skeleton />;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
<button onClick={() => refetch()}>Refresh</button>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
Mutations with TanStack Query
function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) =>
fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
}).then((r) => r.json()),
// Optimistic update
onMutate: async (newPost) => {
await queryClient.cancelQueries({ queryKey: ["posts"] });
const previous = queryClient.getQueryData(["posts"]);
queryClient.setQueryData(["posts"], (old) => [
...old,
{ ...newPost, id: Date.now() },
]);
return { previous }; // Context for rollback
},
// Rollback on error
onError: (err, newPost, context) => {
queryClient.setQueryData(["posts"], context.previous);
},
// Refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
});
const handleSubmit = (data) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit}>
{mutation.isPending && <p>Creating...</p>}
{mutation.isError && <p>Error: {mutation.error.message}</p>}
{/* form fields */}
</form>
);
}
4. SWR (Stale-While-Revalidate)
Vercel's data fetching library. Lighter than TanStack Query with a simpler API, but fewer advanced features.
import useSWR from "swr";
// Global fetcher
const fetcher = (url) => fetch(url).then((r) => r.json());
function UserProfile({ userId }) {
const { data, error, isLoading, mutate } = useSWR(
`/api/users/${userId}`,
fetcher,
{
revalidateOnFocus: true, // Refetch when window regains focus
revalidateOnReconnect: true, // Refetch on network recovery
dedupingInterval: 2000, // Dedupe requests within 2s
}
);
if (isLoading) return <Skeleton />;
if (error) return <p>Error loading user</p>;
return (
<div>
<h2>{data.name}</h2>
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}
// Conditional fetching — pass null key to skip
function MaybeUser({ userId }) {
const { data } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher
);
return data ? <p>{data.name}</p> : <p>No user selected</p>;
}
5. Infinite Scrolling & Pagination
Both TanStack Query and SWR have built-in support for paginated and infinite data.
import { useInfiniteQuery } from "@tanstack/react-query";
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ["posts"],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/posts?page=${pageParam}&limit=10`).then((r) => r.json()),
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.nextPage : undefined,
initialPageParam: 1,
});
if (isLoading) return <Skeleton />;
return (
<div>
{data.pages.map((page) =>
page.posts.map((post) => <PostCard key={post.id} post={post} />)
)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading more..." : "Load More"}
</button>
)}
</div>
);
}
6. React Suspense for Data Fetching
Suspense lets you declaratively specify loading states. Libraries like TanStack Query integrate with Suspense out of the box.
import { Suspense } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
// This component suspends while loading
function UserDetails({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
// No loading check needed — Suspense handles it!
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Parent wraps in Suspense
function UserPage({ userId }) {
return (
<Suspense fallback={<Skeleton />}>
<UserDetails userId={userId} />
</Suspense>
);
}
// Multiple Suspense boundaries for granular loading
function Dashboard() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<CardSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
7. Server Components (Next.js)
With React Server Components (RSC), you can fetch data directly in your component without useEffect or client-side libraries. The data fetching happens on the server.
// app/users/page.tsx — Server Component (default in Next.js App Router)
// No "use client" directive = Server Component
async function UsersPage() {
// Direct async/await — no hooks, no loading state
const res = await fetch("https://api.example.com/users", {
next: { revalidate: 60 }, // ISR: revalidate every 60 seconds
});
const users = await res.json();
return (
<div>
<h1>Users</h1>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
// Works with databases too
import { db } from "@/lib/db";
async function PostsPage() {
const posts = await db.post.findMany({
orderBy: { createdAt: "desc" },
take: 10,
});
return posts.map((post) => <PostCard key={post.id} post={post} />);
}
8. Error Handling Patterns
Robust error handling is essential for production data fetching.
// Create a typed API client
class ApiError extends Error {
constructor(message, status, data) {
super(message);
this.status = status;
this.data = data;
}
}
async function api(url, options = {}) {
const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...options.headers },
...options,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new ApiError(
data?.message || `HTTP ${res.status}`,
res.status,
data
);
}
return res.json();
}
// Use with TanStack Query
function UserProfile({ userId }) {
const { data, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => api(`/api/users/${userId}`),
retry: (failureCount, error) => {
// Don't retry 4xx errors
if (error.status >= 400 && error.status < 500) return false;
return failureCount < 3;
},
});
if (error) {
if (error.status === 404) return <NotFound />;
if (error.status === 403) return <Forbidden />;
return <GenericError message={error.message} />;
}
return <div>{data?.name}</div>;
}
9. Prefetching & Deferred Data
Prefetch data before the user navigates to improve perceived performance.
import { useQueryClient } from "@tanstack/react-query";
function PostList({ posts }) {
const queryClient = useQueryClient();
return posts.map((post) => (
<Link
key={post.id}
href={`/posts/${post.id}`}
// Prefetch on hover
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: ["post", post.id],
queryFn: () => api(`/api/posts/${post.id}`),
staleTime: 5 * 60 * 1000,
});
}}
>
{post.title}
</Link>
));
}
✅ Which Library Should You Use?
| Scenario | Recommendation |
|---|---|
| Learning / simple app | Custom useFetch hook |
| Client-side SPA | TanStack Query — most features, best DX |
| Next.js / Vercel stack | SWR — lightweight, great Next.js integration |
| Next.js App Router | Server Components + TanStack Query for client parts |
| Complex CRUD / admin | TanStack Query with mutations & optimistic updates |
| Real-time data | WebSockets + TanStack Query for initial load |
📋 Data Fetching Checklist
- ✅ Always handle loading, error, and success states
- ✅ Abort in-flight requests on unmount or dependency change (AbortController)
- ✅ Deduplicate requests — don't fetch the same data from multiple components
- ✅ Cache responses — avoid refetching data that hasn't changed
- ✅ Show stale data while revalidating — never show a blank screen when you can show cached data
- ✅ Retry on failure — with exponential backoff for 5xx errors
- ✅ Prefetch data — on hover or route transition for instant navigation
- ✅ Type your API responses — use TypeScript interfaces for all API data