Most React applications need to fetch data from APIs - user profiles, product lists, blog posts, and more. Data fetching is one of the most common uses of useEffect. You'll learn how to make API calls, handle loading and error states, prevent race conditions, and build reusable data fetching patterns. Let's connect your React apps to the real world! š
Basic Data Fetching
Here's the simplest way to fetch data when a component mounts:
function UserProfile() {
const [user, setUser] = React.useState(null)
React.useEffect(() => {
// Fetch data when component mounts
fetch('https://jsonplaceholder.typicode.com/users/1')
.then(response => response.json())
.then(data => setUser(data))
}, []) // Empty array = fetch once on mount
if (!user) {
return <p>Loading...</p>
}
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
</div>
)
}
// Flow:
// 1. Component renders (user is null)
// 2. Shows "Loading..."
// 3. useEffect runs fetch
// 4. Data arrives, setUser called
// 5. Component re-renders with user dataThe Fetch API:
fetch() is a browser API for making HTTP requests.
⢠Returns a Promise
⢠First .then() converts response to JSON
⢠Second .then() receives the actual data
⢠Works in all modern browsers
Handling Loading State
Always show users when data is loading:
function UserProfile() {
const [user, setUser] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
React.useEffect(() => {
setIsLoading(true) // Start loading
fetch('https://jsonplaceholder.typicode.com/users/1')
.then(response => response.json())
.then(data => {
setUser(data)
setIsLoading(false) // Stop loading
})
}, [])
if (isLoading) {
return <p>Loading user data...</p>
}
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
)
}
// Better UX - explicit loading stateError Handling
Always handle errors - networks fail, APIs go down:
function UserProfile() {
const [user, setUser] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState(null)
React.useEffect(() => {
setIsLoading(true)
setError(null) // Clear previous errors
fetch('https://jsonplaceholder.typicode.com/users/1')
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch user')
}
return response.json()
})
.then(data => {
setUser(data)
setIsLoading(false)
})
.catch(error => {
setError(error.message)
setIsLoading(false)
})
}, [])
if (isLoading) {
return <p>Loading...</p>
}
if (error) {
return <p style={{ color: 'red' }}>Error: {error}</p>
}
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
)
}
// Three states: loading, error, successā ļø Response Status Check
fetch() only rejects on network errors!
It doesn't reject on 404, 500, etc.
Always check response.ok:if (!response.ok) throw new Error('...')
Or check status code:if (response.status !== 200) ...
Using Async/Await
Async/await makes code more readable than Promise chains:
function UserProfile() {
const [user, setUser] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState(null)
React.useEffect(() => {
// Define async function inside useEffect
const fetchUser = async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch('https://jsonplaceholder.typicode.com/users/1')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setUser(data)
} catch (error) {
setError(error.message)
} finally {
setIsLoading(false)
}
}
fetchUser() // Call the async function
}, [])
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
)
}
// Cleaner with async/await!
// Note: Can't make useEffect callback itself asyncWhy Not Async useEffect Directly?
ā This doesn't work:useEffect(async () => { ... }, [])
useEffect expects a cleanup function or nothing.
Async functions return a Promise, not a function.
ā
Instead, define async function inside:useEffect(() => { async function fetch() {...} fetch() }, [])
Fetching Based on Props/State
Often you need to fetch data based on dynamic values:
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState(null)
React.useEffect(() => {
const fetchUser = async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
)
if (!response.ok) {
throw new Error('User not found')
}
const data = await response.json()
setUser(data)
} catch (error) {
setError(error.message)
} finally {
setIsLoading(false)
}
}
fetchUser()
}, [userId]) // Re-fetch when userId changes!
if (isLoading) return <p>Loading user {userId}...</p>
if (error) return <p>Error: {error}</p>
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
)
}
// Usage:
// <UserProfile userId={1} />
// When userId prop changes, effect re-runs and fetches new userPreventing Race Conditions
Race conditions happen when requests return out of order:
// ā PROBLEM: Race condition
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null)
React.useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
}, [userId])
return <div>{user?.name}</div>
}
// Scenario:
// 1. User clicks profile ID 1 (slow request, takes 3 seconds)
// 2. User clicks profile ID 2 (fast request, takes 1 second)
// 3. Response for ID 2 arrives first, shows user 2
// 4. Response for ID 1 arrives later, shows user 1 (wrong!)
//
// Result: User clicked ID 2 but sees user 1!Solution: Ignore Stale Requests
// ā
SOLUTION: Use ignore flag
function UserProfile({ userId }) {
const [user, setUser] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
React.useEffect(() => {
let ignore = false // Flag to ignore stale responses
const fetchUser = async () => {
setIsLoading(true)
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
// Only update state if this is still the current request
if (!ignore) {
setUser(data)
setIsLoading(false)
}
}
fetchUser()
// Cleanup: Mark this request as stale if userId changes
return () => {
ignore = true
}
}, [userId])
if (isLoading) return <p>Loading...</p>
return <div>{user.name}</div>
}
// How it works:
// 1. User clicks ID 1, ignore = false
// 2. User clicks ID 2, cleanup sets first ignore = true, new ignore = false
// 3. Response for ID 2 arrives, ignore is false, updates state
// 4. Response for ID 1 arrives, ignore is true, doesn't update state!Complete Data Fetching Pattern
Here's a production-ready pattern with all best practices:
function UserProfile({ userId }) {
const [data, setData] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState(null)
React.useEffect(() => {
let ignore = false // Prevent race conditions
const fetchUser = async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
// Only update if this request is still relevant
if (!ignore) {
setData(data)
}
} catch (error) {
// Only set error if this request is still relevant
if (!ignore) {
setError(error.message)
}
} finally {
// Only update loading if this request is still relevant
if (!ignore) {
setIsLoading(false)
}
}
}
fetchUser()
// Cleanup function
return () => {
ignore = true
}
}, [userId]) // Re-run when userId changes
// Loading state
if (isLoading) {
return (
<div>
<p>Loading user {userId}...</p>
</div>
)
}
// Error state
if (error) {
return (
<div>
<p style={{ color: 'red' }}>Error: {error}</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
)
}
// Success state
return (
<div>
<h1>{data.name}</h1>
<p>Email: {data.email}</p>
<p>Phone: {data.phone}</p>
<p>Website: {data.website}</p>
</div>
)
}
// Production-ready pattern includes:
// ā
Loading state
// ā
Error handling
// ā
Race condition prevention
// ā
Cleanup function
// ā
Proper dependencies
// ā
User-friendly UI for all statesInteractive Demo
š” Tip: Edit the code above and click "Run" to see your changes
Custom useFetch Hook
Reuse data fetching logic by creating a custom hook:
function useFetch(url) {
const [data, setData] = React.useState(null)
const [isLoading, setIsLoading] = React.useState(true)
const [error, setError] = React.useState(null)
React.useEffect(() => {
let ignore = false
const fetchData = async () => {
try {
setIsLoading(true)
setError(null)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
if (!ignore) {
setData(data)
}
} catch (error) {
if (!ignore) {
setError(error.message)
}
} finally {
if (!ignore) {
setIsLoading(false)
}
}
}
fetchData()
return () => {
ignore = true
}
}, [url]) // Re-fetch when URL changes
return { data, isLoading, error }
}
// Usage:
function UserProfile({ userId }) {
const { data, isLoading, error } = useFetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
)
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
return (
<div>
<h1>{data.name}</h1>
<p>Email: {data.email}</p>
</div>
)
}
// Much cleaner! Reusable across componentsData Fetching Best Practices
- Always handle loading state - Show users what's happening
- Always handle errors - Networks fail, APIs go down
- Check response.ok - Fetch doesn't reject on 404, 500, etc.
- Prevent race conditions - Use ignore flag in cleanup
- Use async/await - Cleaner than Promise chains
- Include dependencies - Re-fetch when props/state change
- Create custom hooks - Reuse fetching logic
- Consider loading indicators - Spinners, skeletons, etc.
- Provide retry mechanism - Let users retry failed requests
- Cache when appropriate - Don't refetch unnecessarily
- Consider libraries - React Query, SWR for advanced needs
- Handle empty states - No data found messages
When to Use Data Fetching Libraries:
For complex apps with lots of data fetching, consider libraries like:
⢠React Query - Caching, refetching, mutations
⢠SWR - Stale-while-revalidate pattern
⢠RTK Query - With Redux Toolkit
These handle caching, deduplication, polling, and more automatically!
Key Takeaways
- Use
useEffectfor data fetching on mount or when dependencies change - Always track loading, error, and data states
- Use async/await inside useEffect (define function inside)
- Check
response.ok- fetch doesn't reject on HTTP errors - Prevent race conditions with ignore flag in cleanup function
- Include URL/params in dependency array
- Use try/catch for error handling
- Create custom hooks to reuse fetching logic
- Show loading indicators for better UX
- Provide retry mechanism for failed requests
- Consider data fetching libraries for complex apps
- Data fetching is one of the most common useEffect use cases
What's Next?
You now know how to fetch data professionally in React! You can handle loading and error states, prevent race conditions, and build reusable fetching patterns.
In the next lesson, you'll learn about Cleanup Functions:
- When and why to use cleanup functions
- Preventing memory leaks
- Cleaning up subscriptions and timers
- Aborting fetch requests
- Common cleanup patterns
Get ready to write bulletproof effects with proper cleanup! š§¹