Fetching with useEffect
useEffect is the standard way to perform side effects — including data fetching — in function components. It runs after the component renders, making it the right place to trigger network requests. But getting it right requires understanding dependency arrays, cleanup, and the subtle ordering guarantees React provides.
Complete User Profile Fetcher
The following component fetches a user profile and re-fetches automatically whenever the userId prop changes — a common pattern for detail pages:
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (!userId) return // nothing to fetch
const controller = new AbortController()
setIsLoading(true)
setError(null)
setUser(null) // clear previous user while loading new one
async function fetchUser() {
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }
)
if (!res.ok) {
throw new Error(`Could not load user ${userId} (HTTP ${res.status})`)
}
const data = await res.json()
setUser(data)
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message)
}
} finally {
setIsLoading(false)
}
}
fetchUser()
return () => controller.abort() // cancel on userId change or unmount
}, [userId]) // ← re-run whenever userId changes
if (isLoading) return <p>Loading user {userId}…</p>
if (error) return <p style={{ color: 'red' }}>{error}</p>
if (!user) return null
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.phone}</p>
</div>
)
}The Dependency Array in Depth
The second argument to useEffect controls when the effect re-runs. React compares each dependency with its previous value using Object.is (similar to ===):
[]— run once, on mount only. The effect never re-runs.[userId]— run on mount AND wheneveruserIdchanges.No array at all — run after every render (almost never what you want for fetching).
[user.id]— ifuseris a new object on every render, this runs every render. Depend on primitive values, not object references.
Refetching When a Prop Changes
Including a prop in the dependency array causes the fetch to repeat whenever that prop changes. This is how the UserProfile example above works — changing the userId prop triggers a fresh fetch while cleanly cancelling the previous in-flight request:
function UserBrowser() {
const [selectedId, setSelectedId] = useState(1)
return (
<div style={{ display: 'flex', gap: 24 }}>
<nav>
{[1, 2, 3, 4, 5].map(id => (
<button key={id} onClick={() => setSelectedId(id)}>
User {id}
</button>
))}
</nav>
{/* UserProfile re-fetches automatically when selectedId changes */}
<UserProfile userId={selectedId} />
</div>
)
}Pagination with useEffect
Adding a page state variable and including it in the dependency array makes pagination straightforward:
import { useState, useEffect } from 'react'
const PAGE_SIZE = 10
function PostList() {
const [posts, setPosts] = useState([])
const [page, setPage] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
useEffect(() => {
const controller = new AbortController()
setIsLoading(true)
fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${PAGE_SIZE}`,
{ signal: controller.signal }
)
.then(res => {
if (!res.ok) throw new Error('Fetch failed')
return res.json()
})
.then(data => {
setPosts(prev => page === 1 ? data : [...prev, ...data])
setHasMore(data.length === PAGE_SIZE)
setIsLoading(false)
})
.catch(err => {
if (err.name !== 'AbortError') setIsLoading(false)
})
return () => controller.abort()
}, [page]) // re-fetch whenever page changes
return (
<div>
{posts.map(post => <p key={post.id}>{post.title}</p>)}
{isLoading && <p>Loading…</p>}
{hasMore && !isLoading && (
<button onClick={() => setPage(p => p + 1)}>Load more</button>
)}
</div>
)
}Common Mistakes
These are the errors that trip up developers most often when fetching inside useEffect:
// ✗ Mistake 1: async effect function
// useEffect cannot receive an async function directly
useEffect(async () => { // ← async returns a Promise, not a cleanup fn
const data = await fetch('/api/data').then(r => r.json())
setData(data)
}, [])
// ✓ Fix: define an async function inside the effect
useEffect(() => {
async function load() {
const data = await fetch('/api/data').then(r => r.json())
setData(data)
}
load()
}, [])
// ✗ Mistake 2: missing cleanup
useEffect(() => {
fetch(`/api/user/${userId}`).then(r => r.json()).then(setUser)
// if userId changes before this completes, stale data overwrites fresh data
}, [userId])
// ✓ Fix: cancel with AbortController (shown above)
// ✗ Mistake 3: object in dependency array
useEffect(() => {
fetch(`/api/search?q=${options.query}`).then(...)
}, [options]) // ← 'options' is a new object every render → infinite loop
// ✓ Fix: depend on primitive values
useEffect(() => {
fetch(`/api/search?q=${options.query}`).then(...)
}, [options.query]) // ← string, stable comparisonThe Tearing Problem in Concurrent Mode
React 18 introduced concurrent rendering, where React can pause and resume rendering. This creates a subtle issue called tearing: the UI can display inconsistent state if an external data source changes mid-render.
For useState-based local state this is not a problem — React ensures consistency within its own state system. But if you read from an external store (like a global variable or third-party state) during render, and that store updates while React is rendering, different parts of the tree might see different values of the same data.
Cleanup Is Not Optional
Always return a cleanup function from useEffect when you fetch. Three things can go wrong without it:
Memory leaks —
setStatecalls accumulate on unmounted components.Race conditions — an older request resolves after a newer one and overwrites the correct data.
Strict Mode double-invocation — React 18 Strict Mode deliberately runs effects twice in development to surface missing cleanup. Without cleanup you will see doubled requests and state inconsistencies in dev.
// The pattern to internalize — copy this every time you fetch:
useEffect(() => {
const controller = new AbortController()
async function load() {
try {
setLoading(true)
const res = await fetch(url, { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
setData(json)
} catch (err) {
if (err.name !== 'AbortError') setError(err.message)
} finally {
setLoading(false)
}
}
load()
return () => controller.abort()
}, [url])