Data Fetching
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.
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.
| 1 | import { useState, useEffect } from "react"; |
| 2 | |
| 3 | function 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:
| 1 | function 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 |
| 39 | function 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
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.
| 1 | import useSWR from "swr"; |
| 2 | |
| 3 | const fetcher = (url) => fetch(url).then(res => res.json()); |
| 4 | |
| 5 | // Basic usage — fetches and caches automatically |
| 6 | function 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 |
| 28 | function 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 |
| 44 | function 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
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.
| 1 | import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider } from "@tanstack/react-query"; |
| 2 | |
| 3 | const 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 | |
| 14 | function App() { |
| 15 | return ( |
| 16 | <QueryClientProvider client={queryClient}> |
| 17 | <MyApp /> |
| 18 | </QueryClientProvider> |
| 19 | ); |
| 20 | } |
| 21 | |
| 22 | // Basic query |
| 23 | function 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 |
| 47 | function 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 |
| 60 | function 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 |
| 93 | function 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
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).
| 1 | // Next.js App Router — Server Component (default) |
| 2 | // Data is fetched on the server, HTML is sent to the client |
| 3 | async 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 |
| 19 | async 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"; |
| 35 | function 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 |
| 46 | async 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
Robust error handling and loading states are essential for good user experience. Handle network errors, 404s, timeouts, and provide meaningful feedback to users.
| 1 | // Error boundary pattern for data fetching |
| 2 | function 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 |
| 19 | function 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 |
| 36 | function 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 | } |
Effective caching reduces network requests, improves performance, and creates a snappier user experience. Here are the key strategies:
| 1 | // Strategy 1: Stale-While-Revalidate (SWR) |
| 2 | // Show cached data immediately, update in background |
| 3 | useQuery({ |
| 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 |
| 12 | const queryClient = useQueryClient(); |
| 13 | |
| 14 | const 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 |
| 25 | function 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 |
| 45 | function 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