Data Fetching Basics
Almost every real-world React application needs to load data from an external source — a REST API, a GraphQL endpoint, or even a local JSON file. On the surface it looks simple: call fetch, get data, show it. In practice, data fetching is one of the trickiest parts of React because you have to coordinate three distinct states, avoid memory leaks, handle race conditions, and keep the UI responsive throughout.
The Three States of Every Fetch
Every network request has three possible outcomes that your UI must handle explicitly. Ignoring any one of them produces a broken experience:
State | What happened | What the UI should show |
|---|---|---|
loading | Request is in-flight | Skeleton, spinner, or placeholder |
success | Data arrived successfully | The actual content |
error | Network failure or non-2xx response | Error message, retry button |
The Basic useEffect + fetch Pattern
The standard way to fetch data in a client component is to combine useEffect (to trigger the fetch when the component mounts) with useState (to store the three states). Here is the complete, correct implementation:
import { useState, useEffect } from 'react'
function UserList() {
const [users, setUsers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false // flag for cleanup
async function fetchUsers() {
try {
setIsLoading(true)
setError(null)
const res = await fetch('https://jsonplaceholder.typicode.com/users')
if (!res.ok) {
throw new Error(`HTTP error — status ${res.status}`)
}
const data = await res.json()
if (!cancelled) { // only update state if still mounted
setUsers(data)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
}
} finally {
if (!cancelled) {
setIsLoading(false)
}
}
}
fetchUsers()
return () => {
cancelled = true // cleanup: ignore stale responses
}
}, []) // empty array → run once on mount
if (isLoading) return <p>Loading users…</p>
if (error) return <p>Error: {error}</p>
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
)
}Loading Skeleton UIs
A plain "Loading…" text works, but users expect something that resembles the final layout while data loads. A skeleton UI is a low-fidelity placeholder that mirrors the shape of the real content:
function UserCardSkeleton() {
return (
<div className="card skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-line" style={{ width: '60%' }} />
<div className="skeleton-line" style={{ width: '40%' }} />
</div>
)
}
function UserList() {
const [users, setUsers] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
// ... fetch logic as above ...
if (isLoading) {
return (
<div>
{Array.from({ length: 5 }).map((_, i) => (
<UserCardSkeleton key={i} />
))}
</div>
)
}
// ... rest of render
}Error Handling with try/catch
Two categories of errors can occur during a fetch. You must handle both:
Network errors — the request never completed (no internet, DNS failure, CORS). These throw an exception that
catchreceives.HTTP errors — the server responded, but with a 4xx or 5xx status code.
fetchdoes NOT throw for these — you must checkres.okorres.statusmanually.
// Common mistake: only catching network errors
try {
const res = await fetch('/api/user')
const data = await res.json() // ← succeeds even for a 404!
setUser(data)
} catch (err) {
setError(err.message) // ← 404/500 responses never reach here
}
// Correct: check res.ok too
try {
const res = await fetch('/api/user')
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`)
}
const data = await res.json()
setUser(data)
} catch (err) {
setError(err.message) // catches both network errors AND bad HTTP status
}AbortController for Cleanup
If a component unmounts while a request is still in-flight (user navigates away), the fetch will complete and call setState on an unmounted component. React 18 suppresses the resulting warning, but updating unmounted component state is still a logic error. The correct fix is AbortController:
useEffect(() => {
const controller = new AbortController()
async function fetchData() {
try {
const res = await fetch('/api/data', {
signal: controller.signal // ← pass the abort signal to fetch
})
const data = await res.json()
setData(data)
} catch (err) {
if (err.name === 'AbortError') return // intentional abort, ignore
setError(err.message)
}
}
fetchData()
return () => controller.abort() // cleanup: cancel the in-flight request
}, [])Race Conditions
A race condition occurs when two requests are made in quick succession and the earlier request finishes after the later one. The stale result overwrites the fresh one, leaving the UI out of sync:
// ⚠️ Race condition: user clicks "Alice" then "Bob" rapidly
// Fetch for Alice might complete after Bob's fetch → Alice's data shown
// Fix with AbortController (aborting the previous request)
useEffect(() => {
const controller = new AbortController()
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err.message)
})
return () => controller.abort() // cancel previous request when userId changes
}, [userId]) // ← re-run whenever userId changesServer vs Client Data Fetching
The pattern above is client-side fetching — the browser makes the request after the page has loaded. When using Next.js with React Server Components, you can fetch data on the server instead:
// Next.js Server Component — runs only on the server
// No useEffect, no useState, no loading state needed
export default async function UserList() {
const res = await fetch('https://api.example.com/users', {
next: { revalidate: 60 } // ISR: refresh every 60 seconds
})
if (!res.ok) throw new Error('Failed to fetch users')
const users = await res.json()
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}Client Fetching (useEffect) | Server Fetching (RSC) | |
|---|---|---|
Where it runs | Browser, after page loads | Server, before HTML is sent |
Loading state | Required | Not needed (Suspense optional) |
SEO | Poor (content arrives after crawl) | Excellent |
Bundle size | Fetch logic in JS bundle | Zero client JS |
Auth / secrets | Exposed to client | Safe on server |
When to use | Dynamic user-specific data, real-time | Static or ISR content |
Why Libraries Exist for This
Writing the full pattern manually — loading state, error state, cleanup, race condition prevention, caching, deduplication, background refresh, pagination — is tedious and error-prone. That is exactly why libraries like TanStack Query (React Query) and SWR exist. They implement all of this correctly so you don't have to. For anything beyond trivial one-off fetches, reach for one of them.