Fetching Data in React
Most real React apps don’t just render static content — they load data from a server and display it. Fetching data in React means calling an API (usually with the built-in fetch function) from inside a component, storing the result in state, and letting React re-render the UI once the data arrives. Because fetching is asynchronous and happens outside of React’s normal render flow, it must be done inside an effect, and it introduces a few subtleties — loading states, error handling, race conditions, and cleanup — that every React developer needs to understand.
Overview: How Data Fetching Works in React
React components are pure functions of their props and state: given the same inputs, they should render the same output, with no side effects during rendering. Making a network request is a side effect — it reaches outside the component to talk to a server, and the response arrives later, asynchronously. That means data fetching cannot happen directly in the component body during render. Instead, it belongs inside a useEffect, which React runs after the component has rendered and committed to the DOM.
The typical pattern is: render the component once with some initial state (usually data = null and loading = true), fire off the request inside useEffect, and when the response resolves, call a state setter to store the result. That state update triggers a re-render, and this time the component renders with the real data. This is why almost every data-fetching component needs at least three pieces of state: the data itself, a loading flag, and an error value.
Because effects can re-run (for example, when a dependency like an id prop changes), React does not automatically cancel a request that’s already in flight when the component re-runs the effect or unmounts. If you don’t handle this, you can end up with a race condition: an old, slow request resolves after a newer one and overwrites the current data with stale results. Handling this correctly — usually with a cleanup flag or AbortController — is one of the most important things to get right in real apps.
It also matters that useEffect runs after every render where its dependencies changed, and its cleanup function runs before the next effect run and on unmount. This lifecycle (mount → effect fires → maybe re-run on dependency change → cleanup on unmount) is exactly what lets you fetch on mount, re-fetch when a prop changes, and cancel outdated requests.
Syntax
The general shape of a data-fetching effect looks like this:
useEffect(() => {
let ignore = false;
async function loadData() {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const json = await response.json();
if (!ignore) {
setData(json);
}
} catch (err) {
if (!ignore) {
setError(err.message);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
}
loadData();
return () => {
ignore = true;
};
}, [url]);
| Part | Purpose |
|---|---|
useState for data, loading, error |
Tracks the three states every fetch needs to represent: not-yet-loaded, in-progress, success, and failure |
Inner async function |
The effect callback itself cannot be async (React expects it to return either nothing or a cleanup function, not a Promise), so you declare and call an async helper inside it |
ignore flag |
Set to true in the cleanup function so a stale response can’t overwrite newer state after the effect re-runs or the component unmounts |
Dependency array ([url]) |
Re-runs the fetch whenever url changes; must list every reactive value read inside the effect |
Examples
Example 1: A basic fetch on mount
import { useState, useEffect } from "react";
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("https://api.example.com/users/1")
.then((response) => response.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) {
return <p>Loading...</p>;
}
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
Renders: “Loading…” immediately on mount, then re-renders to show the user’s name and email once the fetch resolves. The empty dependency array ([]) means this effect runs exactly once, right after the first render — a classic “fetch on mount” pattern. This version is intentionally simplified: it has no error handling and no cancellation, which is fine for a first example but not for production code (see Common Mistakes below).
Example 2: Handling loading, error, and success states
import { useState, useEffect } from "react";
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
async function fetchPosts() {
setLoading(true);
setError(null);
try {
const response = await fetch("https://api.example.com/posts");
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
if (!ignore) setPosts(data);
} catch (err) {
if (!ignore) setError(err.message);
} finally {
if (!ignore) setLoading(false);
}
}
fetchPosts();
return () => {
ignore = true;
};
}, []);
if (loading) return <p>Loading posts...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default PostList;
Renders: “Loading posts…”, then either a bulleted list of post titles (each with a stable key) or an “Error: …” message if the request fails. This is the realistic minimum for a data-fetching component: three explicit states so the UI never shows blank or stale content while something is happening in the background.
Example 3: Re-fetching when a prop changes, with request cancellation
import { useState, useEffect } from "react";
function ProductDetails({ productId }) {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
async function fetchProduct() {
setLoading(true);
try {
const response = await fetch(
`https://api.example.com/products/${productId}`,
{ signal: controller.signal }
);
const data = await response.json();
setProduct(data);
} catch (err) {
if (err.name !== "AbortError") {
console.error("Fetch failed:", err.message);
}
} finally {
setLoading(false);
}
}
fetchProduct();
return () => controller.abort();
}, [productId]);
if (loading) return <p>Loading product...</p>;
return <h3>{product.name} - ${product.price}</h3>;
}
export default ProductDetails;
Renders: The current product’s name and price. If productId changes quickly (e.g. the user clicks through several products), each previous in-flight request is aborted via controller.abort() in the cleanup function, so only the latest request’s result is ever applied to state. This is the pattern to use whenever an effect’s dependency can change before the previous fetch finishes.
How It Works Step by Step
- On mount: The component renders once with initial state (e.g.
data: null, loading: true). After the DOM commits, React runs the effect, which starts the fetch. - While pending: The UI shows a loading state because
loadingis stilltrue— no state has changed yet since the request hasn’t resolved. - On success: The
.then()/awaitcontinuation callssetData(json)andsetLoading(false). Each state setter schedules a re-render; React batches them into a single re-render pass. - On a dependency change: If the effect’s dependency array includes a value that changes (like
productId), React runs the cleanup function from the previous effect call first (aborting or ignoring the old request), then runs the effect again with the new value. - On unmount: React runs the cleanup function one final time. This is what prevents the classic “Can’t perform a React state update on an unmounted component” warning — the abort or
ignoreflag stops a late-arriving response from calling a state setter after the component is gone.
Common Mistakes
Mistake 1: Forgetting the dependency array (infinite fetch loop)
useEffect(() => {
fetch(url).then((res) => res.json()).then(setData);
}); // no dependency array!
Without a dependency array, this effect runs after every render. Since setData triggers a re-render, and the re-render triggers the effect again, this fetches in an infinite loop. Fix it by adding a dependency array — [] to fetch once on mount, or [url] to re-fetch only when url changes:
useEffect(() => {
fetch(url).then((res) => res.json()).then(setData);
}, [url]);
Mistake 2: Not handling race conditions between requests
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => setResults(data)); // may apply a stale response
}, [query]);
If the user types quickly, an earlier, slower request can resolve after a newer one and overwrite fresh results with stale ones. Fix it with a cleanup flag (or AbortController, shown in Example 3) so only the latest effect’s response is ever applied:
useEffect(() => {
let ignore = false;
fetch(`/api/search?q=${query}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setResults(data);
});
return () => {
ignore = true;
};
}, [query]);
Mistake 3: Treating a non-2xx HTTP response as success
fetch only rejects on network failure — a 404 or 500 response still resolves normally, so code that skips checking response.ok silently treats error pages as valid data. Always check response.ok (or the status code) and throw before parsing the body, as shown in Example 2.
Best Practices
- Always track
loadinganderroralongside the data itself — a data-fetching UI has at least three states, not one. - Cancel or ignore stale requests (
AbortControlleror anignoreflag) whenever the effect’s dependencies can change before a fetch resolves. - Check
response.okbefore callingresponse.json(), and wrap the whole sequence intry/catchso both network errors and HTTP errors are caught. - List every reactive value the effect reads (URL, query params, IDs) in the dependency array so re-fetches happen exactly when they should — don’t suppress the exhaustive-deps lint rule to make a warning disappear.
- Extract repeated fetch logic into a custom hook (e.g.
useFetch(url)) so components stay focused on rendering instead of repeating request boilerplate. - For anything beyond a small app, consider a dedicated data-fetching library (like React Query or SWR) — they handle caching, retries, and race conditions for you, but understanding the raw
useEffectpattern first is essential to using them well.
Practice Exercises
- Exercise 1: Build a
WeatherWidgetcomponent that fetches weather data for a city name passed in as a prop. Show a loading message while fetching and an error message if the request fails. - Exercise 2: Extract the fetch logic from Example 2 into a reusable custom hook called
useFetch(url)that returns{ data, loading, error }, then use it in two different components. - Exercise 3: Add a search input that fetches results as the user types, using an
ignoreflag orAbortControllerto make sure only the response for the most recent keystroke ever updates the results list.
Summary
- Data fetching is a side effect, so it belongs inside
useEffect, not directly in the component body. - Track
loading,error, and the data itself as separate state so the UI can represent every stage of the request. - An effect’s dependency array controls when it re-runs; an empty array means “once on mount.”
- Use a cleanup flag or
AbortControllerto prevent stale responses from overwriting newer state — this avoids both race conditions and updates after unmount. - Always check
response.okbefore parsing JSON, sincefetchdoes not reject on HTTP error statuses. - Custom hooks like
useFetchkeep fetching logic reusable and components focused on rendering.
