|$ curl https://forge-ai.dev/api/markdown?path=docs/react/data-fetching
$cat docs/data-fetching.md
updated Recently·40 min read·published

Data Fetching

ReactDataAPIsIntermediate to Advanced🎯Free Tools
Introduction

Data fetching is one of the most common tasks in React applications. Whether loading user profiles, product listings, or search results, how you fetch, cache, and update data significantly impacts user experience, performance, and code maintainability.

React doesn't have a built-in data fetching mechanism — it's a UI library, not a data layer. This means you choose how to fetch: plain fetch with useEffect, or dedicated libraries like SWR or TanStack Query that handle caching, deduplication, and revalidation automatically.

useEffect + Fetch

The simplest approach is using useEffect with the native fetch API. It works but requires manual handling of loading states, errors, caching, deduplication, and cleanup.

useeffect-fetch.jsx
JSX
1import { useState, useEffect } from "react";
2
3function UserList() {
4 const [users, setUsers] = useState([]);
5 const [loading, setLoading] = useState(true);
6 const [error, setError] = useState(null);
7
8 useEffect(() => {
9 const controller = new AbortController();
10
11 async function fetchUsers() {
12 setLoading(true);
13 setError(null);
14
15 try {
16 const response = await fetch("/api/users", {
17 signal: controller.signal,
18 });
19
20 if (!response.ok) {
21 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
22 }
23
24 const data = await response.json();
25 setUsers(data);
26 } catch (err) {
27 if (err.name !== "AbortError") {
28 setError(err.message);
29 }
30 } finally {
31 setLoading(false);
32 }
33 }
34
35 fetchUsers();
36
37 return () => controller.abort();
38 }, []);
39
40 if (loading) return <p>Loading users...</p>;
41 if (error) return <p>Error: {error}</p>;
42 if (users.length === 0) return <p>No users found</p>;
43
44 return (
45 <ul>
46 {users.map(user => (
47 <li key={user.id}>{user.name}</li>
48 ))}
49 </ul>
50 );
51}

Custom fetch hook to avoid repetition:

usefetch-hook.jsx
JSX
1function useFetch(url, options = {}) {
2 const [data, setData] = useState(null);
3 const [error, setError] = useState(null);
4 const [loading, setLoading] = useState(true);
5
6 useEffect(() => {
7 if (!url) {
8 setLoading(false);
9 return;
10 }
11
12 const controller = new AbortController();
13
14 async function fetchData() {
15 setLoading(true);
16 try {
17 const res = await fetch(url, { ...options, signal: controller.signal });
18 if (!res.ok) throw new Error(`HTTP ${res.status}`);
19 const json = await res.json();
20 setData(json);
21 setError(null);
22 } catch (err) {
23 if (err.name !== "AbortError") {
24 setError(err);
25 }
26 } finally {
27 setLoading(false);
28 }
29 }
30
31 fetchData();
32 return () => controller.abort();
33 }, [url]);
34
35 return { data, error, loading, refetch: () => setLoading(true) };
36}
37
38// Usage
39function ProductPage({ productId }) {
40 const { data: product, loading, error } = useFetch(`/api/products/${productId}`);
41
42 if (loading) return <Spinner />;
43 if (error) return <ErrorMessage error={error} />;
44 return <ProductDetails product={product} />;
45}

warning

The useEffect+fetch pattern has significant limitations: no caching (refetching on every mount), no deduplication (multiple components fetching the same URL make duplicate requests), no stale-while-revalidate, and no background refetching. For production apps, use a data fetching library.
SWR — Stale-While-Revalidate

SWR (by Vercel) is a lightweight data fetching hook that implements the stale-while-revalidate strategy. It returns cached data first, then fetches fresh data, and revalidates in the background. SWR deduplicates requests, caches responses, and automatically re-fetches on focus/reconnect.

swr.jsx
JSX
1import useSWR from "swr";
2
3const fetcher = (url) => fetch(url).then(res => res.json());
4
5// Basic usage — fetches and caches automatically
6function UserProfile({ userId }) {
7 const { data, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher);
8
9 if (isLoading) return <Spinner />;
10 if (error) return <ErrorMessage error={error} />;
11
12 return (
13 <div>
14 <h2>{data.name}</h2>
15 <p>{data.email}</p>
16 </div>
17 );
18}
19
20// Global fetcher configuration
21// In your app root:
22// import { SWRConfig } from "swr";
23// <SWRConfig value={{ fetcher, refreshInterval: 30000 }}>
24// <App />
25// </SWRConfig>
26
27// Dependent fetching — second request depends on first
28function Dashboard() {
29 const { data: user } = useSWR("/api/me");
30 const { data: projects } = useSWR(
31 user ? `/api/users/${user.id}/projects` : null
32 );
33
34 if (!user) return <Spinner />;
35 return (
36 <div>
37 <h1>Welcome, {user.name}</h1>
38 {projects?.map(p => <ProjectCard key={p.id} project={p} />)}
39 </div>
40 );
41}
42
43// Mutations — update data optimistically
44function TodoItem({ todo }) {
45 const { mutate } = useSWR("/api/todos");
46
47 const toggleTodo = async () => {
48 // Optimistic update
49 mutate(
50 (current) => current.map(t =>
51 t.id === todo.id ? { ...t, done: !t.done } : t
52 ),
53 false // don't revalidate yet
54 );
55
56 await fetch(`/api/todos/${todo.id}`, {
57 method: "PATCH",
58 body: JSON.stringify({ done: !todo.done }),
59 });
60
61 mutate(); // revalidate to get server state
62 };
63
64 return (
65 <div onClick={toggleTodo}>
66 {todo.done ? "✓" : "○"} {todo.text}
67 </div>
68 );
69}

info

SWR is ideal for client-side data fetching with minimal configuration. It's smaller (~4KB), simpler than TanStack Query, and perfect for most applications. Use it when you need basic caching, deduplication, and automatic revalidation.
TanStack Query (React Query)

TanStack Query is the most powerful data fetching library for React. It provides caching, background refetching, pagination, infinite scrolling, optimistic updates, and query invalidation. It's the standard for production applications.

tanstack-query.jsx
JSX
1import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
3const queryClient = new QueryClient({
4 defaultOptions: {
5 queries: {
6 staleTime: 5 * 60 * 1000, // 5 minutes
7 gcTime: 10 * 60 * 1000, // 10 minutes (garbage collection)
8 retry: 3,
9 refetchOnWindowFocus: true,
10 },
11 },
12});
13
14function App() {
15 return (
16 <QueryClientProvider client={queryClient}>
17 <MyApp />
18 </QueryClientProvider>
19 );
20}
21
22// Basic query
23function UserList() {
24 const { data, error, isLoading, isFetching } = useQuery({
25 queryKey: ["users"],
26 queryFn: async () => {
27 const res = await fetch("/api/users");
28 if (!res.ok) throw new Error("Failed to fetch users");
29 return res.json();
30 },
31 });
32
33 if (isLoading) return <Spinner />;
34 if (error) return <ErrorMessage error={error} />;
35
36 return (
37 <div>
38 {isFetching && <span className="text-xs">Refreshing...</span>}
39 {data.map(user => (
40 <UserCard key={user.id} user={user} />
41 ))}
42 </div>
43 );
44}
45
46// Query with parameters
47function UserDetail({ userId }) {
48 const { data: user, isLoading } = useQuery({
49 queryKey: ["users", userId],
50 queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
51 enabled: !!userId, // only run when userId is truthy
52 staleTime: 60000,
53 });
54
55 if (isLoading) return <Spinner />;
56 return <UserProfile user={user} />;
57}
58
59// Mutation with cache invalidation
60function CreateUserForm() {
61 const queryClient = useQueryClient();
62
63 const mutation = useMutation({
64 mutationFn: (newUser) =>
65 fetch("/api/users", {
66 method: "POST",
67 headers: { "Content-Type": "application/json" },
68 body: JSON.stringify(newUser),
69 }).then(r => r.json()),
70
71 onSuccess: () => {
72 // Invalidate and refetch user list
73 queryClient.invalidateQueries({ queryKey: ["users"] });
74 },
75 });
76
77 const handleSubmit = (e) => {
78 e.preventDefault();
79 mutation.mutate({ name: "New User", email: "user@example.com" });
80 };
81
82 return (
83 <form onSubmit={handleSubmit}>
84 <button type="submit" disabled={mutation.isPending}>
85 {mutation.isPending ? "Creating..." : "Create User"}
86 </button>
87 {mutation.isError && <p>Error: {mutation.error.message}</p>}
88 </form>
89 );
90}
91
92// Infinite scrolling query
93function InfiniteUserList() {
94 const {
95 data,
96 fetchNextPage,
97 hasNextPage,
98 isFetchingNextPage,
99 isLoading,
100 } = useInfiniteQuery({
101 queryKey: ["users", "infinite"],
102 queryFn: ({ pageParam = 1 }) =>
103 fetch(`/api/users?page=${pageParam}&limit=20`).then(r => r.json()),
104 getNextPageParam: (lastPage) => lastPage.nextPage,
105 initialPageParam: 1,
106 });
107
108 if (isLoading) return <Spinner />;
109
110 return (
111 <div>
112 {data.pages.map((page, i) => (
113 <div key={i}>
114 {page.users.map(user => (
115 <UserCard key={user.id} user={user} />
116 ))}
117 </div>
118 ))}
119 <button
120 onClick={() => fetchNextPage()}
121 disabled={!hasNextPage || isFetchingNextPage}
122 >
123 {isFetchingNextPage ? "Loading more..." : hasNextPage ? "Load More" : "No more"}
124 </button>
125 </div>
126 );
127}
🔥

pro tip

TanStack Query's queryKey is the key to its power. It uniquely identifies each query for caching, deduplication, and invalidation. Use arrays like ["users", userId] — changing any part of the key triggers a refetch.
Server-Side Rendering Patterns

In frameworks like Next.js, data can be fetched on the server before rendering. This improves performance (no loading spinners for initial data) and SEO (search engines see the full content).

ssr-patterns.jsx
JSX
1// Next.js App Router — Server Component (default)
2// Data is fetched on the server, HTML is sent to the client
3async function ProductPage({ params }) {
4 const { id } = await params;
5 const product = await fetch(`https://api.example.com/products/${id}`, {
6 cache: "force-cache", // static data — cache forever
7 }).then(r => r.json());
8
9 return (
10 <div>
11 <h1>{product.name}</h1>
12 <p>{product.description}</p>
13 <ProductReviews productId={id} />
14 </div>
15 );
16}
17
18// Client Component with pre-fetched data via Server Component
19async function ProductPage({ params }) {
20 const { id } = await params;
21 const product = await getProduct(id);
22
23 return (
24 <div>
25 <h1>{product.name}</h1>
26 {/* Pass server-fetched data to client component */}
27 <ProductActions product={product} />
28 <ReviewsSection productId={id} />
29 </div>
30 );
31}
32
33// Client Component — uses TanStack Query for dynamic data
34"use client";
35function ReviewsSection({ productId }) {
36 const { data, isLoading } = useQuery({
37 queryKey: ["reviews", productId],
38 queryFn: () => fetch(`/api/products/${productId}/reviews`).then(r => r.json()),
39 });
40
41 if (isLoading) return <Spinner />;
42 return data.reviews.map(r => <Review key={r.id} review={r} />);
43}
44
45// Parallel data fetching
46async function DashboardPage() {
47 const [user, notifications, stats] = await Promise.all([
48 fetch("/api/me").then(r => r.json()),
49 fetch("/api/notifications").then(r => r.json()),
50 fetch("/api/stats").then(r => r.json()),
51 ]);
52
53 return (
54 <Dashboard user={user} notifications={notifications} stats={stats} />
55 );
56}

best practice

Use Server Components for static data (product pages, blog posts, marketing pages). Use Client Components with TanStack Query for dynamic, user-specific data (dashboards, real-time updates, search results). Combine both for the best performance.
Error Handling & Loading States

Robust error handling and loading states are essential for good user experience. Handle network errors, 404s, timeouts, and provide meaningful feedback to users.

error-loading.jsx
JSX
1// Error boundary pattern for data fetching
2function DataErrorBoundary({ children, fallback }) {
3 return (
4 <ErrorBoundary
5 fallbackRender={({ error, resetErrorBoundary }) => (
6 <div className="error-container">
7 <h2>Something went wrong</h2>
8 <p>{error.message}</p>
9 <button onClick={resetErrorBoundary}>Try again</button>
10 </div>
11 )}
12 >
13 {children}
14 </ErrorBoundary>
15 );
16}
17
18// Skeleton loading pattern
19function UserListSkeleton() {
20 return (
21 <div className="space-y-4">
22 {Array.from({ length: 5 }).map((_, i) => (
23 <div key={i} className="flex items-center gap-4 animate-pulse">
24 <div className="w-10 h-10 rounded-full bg-gray-700" />
25 <div className="flex-1">
26 <div className="h-4 bg-gray-700 rounded w-1/3 mb-2" />
27 <div className="h-3 bg-gray-700 rounded w-1/2" />
28 </div>
29 </div>
30 ))}
31 </div>
32 );
33}
34
35// Optimistic updates pattern
36function TodoList() {
37 const queryClient = useQueryClient();
38
39 const toggleMutation = useMutation({
40 mutationFn: (todo) =>
41 fetch(`/api/todos/${todo.id}`, {
42 method: "PATCH",
43 body: JSON.stringify({ done: !todo.done }),
44 }),
45
46 onMutate: async (updatedTodo) => {
47 await queryClient.cancelQueries({ queryKey: ["todos"] });
48
49 const previous = queryClient.getQueryData(["todos"]);
50
51 queryClient.setQueryData(["todos"], (old) =>
52 old.map(todo =>
53 todo.id === updatedTodo.id
54 ? { ...todo, done: !todo.done }
55 : todo
56 )
57 );
58
59 return { previous };
60 },
61
62 onError: (err, updatedTodo, context) => {
63 queryClient.setQueryData(["todos"], context.previous);
64 },
65
66 onSettled: () => {
67 queryClient.invalidateQueries({ queryKey: ["todos"] });
68 },
69 });
70
71 return <TodoItems onToggle={toggleMutation.mutate} />;
72}
Caching Strategies

Effective caching reduces network requests, improves performance, and creates a snappier user experience. Here are the key strategies:

caching-strategies.jsx
JSX
1// Strategy 1: Stale-While-Revalidate (SWR)
2// Show cached data immediately, update in background
3useQuery({
4 queryKey: ["products"],
5 queryFn: fetchProducts,
6 staleTime: 60 * 1000, // data is fresh for 60 seconds
7 gcTime: 5 * 60 * 1000, // cache for 5 minutes after unmount
8});
9
10// Strategy 2: Cache Invalidation
11// Invalidate when data changes
12const queryClient = useQueryClient();
13
14const createProduct = useMutation({
15 mutationFn: (newProduct) => api.createProduct(newProduct),
16 onSuccess: () => {
17 // Invalidate specific queries
18 queryClient.invalidateQueries({ queryKey: ["products"] });
19 queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] });
20 },
21});
22
23// Strategy 3: Prefetching
24// Fetch data before user navigates
25function ProductLink({ id }) {
26 const queryClient = useQueryClient();
27
28 const handleMouseEnter = () => {
29 queryClient.prefetchQuery({
30 queryKey: ["product", id],
31 queryFn: () => fetch(`/api/products/${id}`).then(r => r.json()),
32 staleTime: 60000,
33 });
34 };
35
36 return (
37 <a href={`/products/${id}`} onMouseEnter={handleMouseEnter}>
38 View Product
39 </a>
40 );
41}
42
43// Strategy 4: Optimistic updates with rollback
44// Update UI immediately, rollback on error, sync with server
45function ToggleFavorite({ productId, isFavorited }) {
46 const queryClient = useQueryClient();
47
48 const mutation = useMutation({
49 mutationFn: () => api.toggleFavorite(productId),
50 onMutate: async () => {
51 await queryClient.cancelQueries({ queryKey: ["products"] });
52 const previous = queryClient.getQueryData(["products"]);
53
54 queryClient.setQueryData(["products"], (old) =>
55 old.map(p => p.id === productId ? { ...p, favorited: !isFavorited } : p)
56 );
57
58 return { previous };
59 },
60 onError: (err, vars, context) => {
61 queryClient.setQueryData(["products"], context.previous);
62 },
63 onSettled: () => queryClient.invalidateQueries({ queryKey: ["products"] }),
64 });
65
66 return (
67 <button onClick={() => mutation.mutate()}>
68 {isFavorited ? "❤️" : "🤍"}
69 </button>
70 );
71}
🔥

pro tip

TanStack Query is a caching library, not just a fetching library. Think of it as a client-side cache for your server state. The staleTime option is the most important setting — it controls how long data is considered fresh before re-fetching.