useEffect for Data Fetching: Complete Guide Part 2
Data fetching is the most common side effect in React applications. This guide teaches you to build components that fetch API data, manage loading and error states, and conditionally render results—the three-state pattern that underpins nearly every production React component that interacts with a server.
Key Takeaways
- API requests have three distinct states: loading (waiting for response), success (data received), and error (failure). Track all three with separate state variables
- Use
useEffectwith an empty dependency array[]to fetch data exactly once when a component mounts, preventing infinite refetch loops - Implement conditional rendering with
if (loading),if (error), and default return to show the appropriate UI for each state - Always use a
try...catch...finallyblock when fetching: try gets data, catch handles errors, finally setsloadingto false (runs either way) - Check
response.okafter fetch to catch HTTP errors (404, 500, etc.) before trying to parse JSON, preventing misleading error messages
The Three States of a Network Request
Every API call progresses through three states. Your component must handle all three to provide a complete user experience.
Loading: Request sent, waiting for response. Show a spinner, skeleton, or "Loading..." message.
Success: Data received. Render the data in your UI.
Error: Request failed (network error, 404, 500, etc.). Show an error message to the user.
Track these with three separate state variables:
const [data, setData] = useState(null); // Holds fetched data
const [loading, setLoading] = useState(true); // true while fetching
const [error, setError] = useState(null); // Holds error object if request fails
Starting loading as true shows the loading state immediately on first render before useEffect runs.
Fetching Data on Mount with useEffect
To fetch data exactly once when a component first appears, use useEffect with an empty dependency array []:
import React, { useState, useEffect } from 'react';
function PostFetcher() {
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Define async function inside the effect
const fetchData = async () => {
try {
// Fetch from API
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
// Check for HTTP errors (404, 500, etc.)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Parse JSON
const data = await response.json();
setPost(data);
} catch (err) {
// Catch network errors or HTTP errors
setError(err);
} finally {
// Always runs—cleanup after request completes
setLoading(false);
}
};
// Call the async function
fetchData();
}, []); // Empty array: run effect only once on mount
// Conditional rendering
if (loading) {
return <div>Loading post...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<h1>{post.title}</h1>
<p>{post.body}</p>
</div>
);
}
export default PostFetcher;
Why [] is critical: Without an empty dependency array, useEffect runs after every render, causing infinite refetch loops. The empty array tells React: "Run this effect only once, right after the initial render."
Step-by-Step Code Breakdown
State Initialization
Three state variables set initial values:
poststarts asnull(no data yet)loadingstarts astrue(show loading immediately)errorstarts asnull(no error yet)
The useEffect Hook
Inside useEffect, you must define an async function (you cannot make the effect callback itself async):
useEffect(() => {
const fetchData = async () => {
// fetch logic here
};
fetchData(); // Call it
}, []);
Try...Catch...Finally Pattern
This pattern robustly handles all outcomes:
Try block: Execute the fetch and JSON parsing. If response is not ok (e.g., 404, 500), manually throw an error so catch can handle it:
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setPost(data);
Catch block: Runs if any error occurs in the try block (network error, manual throw, JSON parsing error):
catch (err) {
setError(err);
}
Finally block: Always runs whether try succeeded or catch ran. Perfect for cleanup:
finally {
setLoading(false); // Request complete, stop showing spinner
}
Conditional Rendering
Render different UI based on state:
if (loading) return <div>Loading...</div>; // Loading state
if (error) return <div>Error: {error.message}</div>; // Error state
return <div>{post.title}</div>; // Success state
Complete Example: User Profile Fetcher
Here's a realistic component that fetches a user profile and handles all states:
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (!response.ok) {
throw new Error(`User not found (HTTP ${response.status})`);
}
const data = await response.json();
setUser(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, []); // Only fetch when component mounts
if (loading) {
return (
<div style={{ padding: '20px' }}>
<div style={{ fontSize: '18px' }}>Loading user profile...</div>
</div>
);
}
if (error) {
return (
<div style={{ color: 'red', padding: '20px' }}>
<strong>Error loading profile:</strong> {error.message}
</div>
);
}
return (
<div style={{ border: '1px solid #ccc', padding: '20px' }}>
<h2>{user.name}</h2>
<p><strong>Email:</strong> {user.email}</p>
<p><strong>Phone:</strong> {user.phone}</p>
<p><strong>Website:</strong> {user.website}</p>
</div>
);
}
export default UserProfile;
Common Mistakes to Avoid
Infinite refetch loop: Forgetting the empty dependency array [] causes useEffect to run after every render, fetching repeatedly:
// WRONG—fetches infinitely
useEffect(() => {
fetchData();
});
Making useEffect async directly: You cannot make the effect callback itself async:
// WRONG
useEffect(async () => {
const data = await fetch(...);
}, []);
Instead, define an async function inside:
// RIGHT
useEffect(() => {
const fetchData = async () => {
const data = await fetch(...);
};
fetchData();
}, []);
Not checking response.ok: Fetch only rejects on network errors, not HTTP errors (404, 500):
// WRONG—doesn't catch 404/500
const response = await fetch(url);
const data = await response.json(); // May fail silently
Always check:
// RIGHT
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
Best Practices for Data Fetching
- Show a loading state immediately: Initialize
loadingastrueso the spinner appears beforeuseEffectruns - Always catch and display errors: Users need to know if something went wrong, not just see a blank page
- Use finally for cleanup: Set
loadingto false in finally so it runs regardless of success or failure - Keep fetching logic simple: If logic becomes complex, extract it into a custom hook (covered in later articles)
- Close the dependency array: Empty
[]prevents refetch loops. Non-empty arrays rerun on dependency changes
Frequently Asked Questions
Why can't I make useEffect async directly?
React expects useEffect callbacks to return either nothing or a cleanup function. Async functions always return a Promise, which React doesn't handle in effects. So instead, define an async function inside and call it. This is the official React pattern.
What's the difference between response.ok and checking response.status?
response.ok is true if status is 200–299 (success range). Checking response.status === 200 is less robust because status 201 (Created) or 204 (No Content) also indicate success. Always use response.ok for clarity.
Should I fetch data in useEffect or a click handler?
Use useEffect to fetch on mount (when page loads or component appears). Use a click handler or form submission handler to refetch on user action. They solve different problems: effects are for automatic side effects; handlers are for user-triggered actions.
How do I stop showing the loading state too quickly?
In real apps, requests often complete in milliseconds, making the loader flicker. Add a minimum delay:
useEffect(() => {
const timer = setTimeout(() => setLoading(false), 500);
return () => clearTimeout(timer); // Cleanup
}, []);
This ensures the loading message displays for at least 500ms, avoiding jank.
Can I fetch from multiple endpoints?
Yes, but use Promise.all() to wait for multiple requests:
const fetchData = async () => {
try {
const [user, posts] = await Promise.all([
fetch(userUrl).then(r => r.json()),
fetch(postsUrl).then(r => r.json())
]);
setUser(user);
setPosts(posts);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
Further Reading
Deepen your understanding of React data fetching and async patterns:
Glossary
useEffect: React hook for performing side effects after render. Takes a callback and optional dependency array.
Async/await: JavaScript syntax for handling asynchronous operations (Promises) with readable, synchronous-looking code.
Fetch API: Browser API for making HTTP requests to servers and APIs. Returns a Promise with the response.
response.ok: Boolean property on fetch response; true if HTTP status is 200–299 (success range).
Dependency array: Optional second argument to useEffect; controls when the effect runs. Empty [] runs once; omitted runs every render.
try...catch...finally: JavaScript error handling pattern: try runs code, catch handles errors, finally runs either way.