Fetching in Effects
Fetching data when a component mounts (or when an input changes) is one of the most common reasons people reach for useEffect. Done naively, it leaks state updates, races requests against each other, and swallows errors. Done correctly, it tracks loading and error states explicitly, cleans up in-flight requests with an AbortController, and re-runs only when the request input actually changes. This page shows the full pattern — and explains why a data-fetching library is usually the better long-term answer.
The shape of a correct fetch effect
A robust fetch effect needs four pieces of state in mind: the data, a loading flag, an error, and a way to ignore results from a stale request. Because effect callbacks cannot be async directly (an async function returns a promise, but an effect must return a cleanup function or nothing), you declare an async helper inside the effect and call it immediately.
import { useEffect, useState } from "react";
function UserCard({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
}
loadUser();
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading…</p>;
if (error) return <p role="alert">Failed to load: {error}</p>;
return <h2>{user.name}</h2>;
}
Output:
Loading…
(then once the request resolves)
Leanne Graham
Why the dependency matters
The effect lists [userId] as its dependency. That is the request input — the value the fetch is keyed on. When userId changes, React tears down the previous effect (running the cleanup, which aborts the old request) and starts a fresh one. If you forget userId, the component keeps showing the first user forever; if you list the wrong thing, you fetch too often or not at all. The dependency array should contain exactly the reactive values the request URL or options depend on.
Pass every value the fetch reads to the dependency array. The React lint rule
react-hooks/exhaustive-depswill flag omissions — treat its warnings as bugs, not noise.
Cleanup prevents stale updates and races
When userId changes quickly (a user clicking through a list), several requests can be in flight at once. Without cleanup, whichever response arrives last wins — which may not be the one matching the current userId. The AbortController solves this: the cleanup calls controller.abort(), cancelling the previous request before the new one starts. An aborted fetch rejects with an AbortError, which we deliberately ignore so it never reaches setError.
If you cannot use AbortController (for example, a non-fetch data source), use an “ignore” flag instead:
useEffect(() => {
let ignore = false;
async function loadUser() {
const data = await getUserSomehow(userId);
if (!ignore) setUser(data);
}
loadUser();
return () => {
ignore = true;
};
}, [userId]);
The flag does not cancel the network call, but it stops the stale result from updating state — which is enough to fix the race and avoid setting state on an unmounted component.
State-handling options at a glance
| Concern | Approach | Notes |
|---|---|---|
| Loading | loading boolean reset at start of each fetch | Reset to true so re-fetches show the spinner |
| Errors | try/catch plus an error state | Re-throw non-OK responses so they hit catch |
| Cancellation | AbortController + signal | Ignore AbortError in the catch block |
| Races (no fetch) | let ignore = false flag | Guards setState, does not cancel I/O |
| Re-fetch trigger | Dependency array [input] | Lists the values the request is keyed on |
You usually want a library
The pattern above is correct, but it only handles one request in one component. Real apps also need caching, deduplication, retries, pagination, background revalidation, and request sharing across components — all of which you would otherwise reimplement by hand. Purpose-built libraries do this for you.
import { useQuery } from "@tanstack/react-query";
function UserCard({ userId }) {
const { data, isPending, error } = useQuery({
queryKey: ["user", userId],
queryFn: async ({ signal }) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
});
if (isPending) return <p>Loading…</p>;
if (error) return <p role="alert">Failed to load: {error.message}</p>;
return <h2>{data.name}</h2>;
}
If you are building a new app, reach for TanStack Query, SWR, or a framework loader (React Router, Next.js) before hand-rolling effects. Save manual fetch effects for the rare one-off where a dependency is not justified.
Best practices
- Declare the
asynchelper inside the effect and call it — never make the effect callback itselfasync. - Reset
loadingtotrueand clearerrorat the start of every fetch so re-fetches behave correctly. - Always return a cleanup that aborts the request (or flips an
ignoreflag) to avoid stale updates and races. - Ignore
AbortErrorin yourcatchblock so cancellations are not reported as failures. - Key the dependency array on the request input, and trust
exhaustive-depsto catch omissions. - Throw on non-OK HTTP responses;
fetchonly rejects on network errors, not 4xx/5xx status codes. - For anything beyond a trivial one-off, prefer a data-fetching library over manual effects.