Effects and Data Fetching
useEffect, dependency arrays, cleanup functions, and fetching data with loading and error states.
Why effects exist
Rendering a component should be a pure calculation: given the same props and state, it returns the same JSX, with no side effects along the way. But real applications need to do things that aren't pure — fetching data from a server, subscribing to a WebSocket, manually setting document.title, starting a timer. These are called side effects, and React gives you a dedicated hook for them so they stay clearly separated from rendering logic: useEffect.
useEffect basics
import { useEffect, useState } from "react";
function PageTitleUpdater() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // re-run only when `count` changes
return (
<button onClick={() => setCount(c => c + 1)}>
Clicked {count} times
</button>
);
}
useEffect takes two arguments: a function to run (the effect itself), and a dependency array that controls when it re-runs.
The dependency array, precisely
| Dependency array | When the effect runs |
|---|---|
| Omitted entirely | After every render |
[] (empty) |
Once, after the first render only |
[a, b] |
After the first render, and again whenever a or b changes between renders |
useEffect(() => {
console.log("Runs after every render");
});
useEffect(() => {
console.log("Runs once, on mount");
}, []);
useEffect(() => {
console.log("Runs on mount, and again whenever userId changes");
}, [userId]);
React compares each dependency to its value from the previous render using Object.is (essentially ===). This is exactly why the array must include every reactive value the effect reads — every prop, state variable, or function defined in the component that the effect's body uses. Leaving one out is the single most common React bug: the effect silently keeps using a stale, captured value from whichever render it last ran in, instead of the current one.
// Bug: `userId` is used inside the effect but missing from the dependency array
function Profile({ userId }) {
useEffect(() => {
fetchUser(userId).then(/* ... */); // stale `userId` if the prop ever changes
}, []); // should be [userId]
}
The eslint-plugin-react-hooks linter's exhaustive-deps rule catches this automatically in most project setups — treat its warnings as bugs, not noise.
Cleanup functions
If an effect sets something up — an event listener, a timer, a subscription — it usually needs to tear that thing down too, or it'll pile up duplicates every time the effect re-runs, and leak once the component unmounts. Do that by returning a function from the effect:
import { useEffect, useState } from "react";
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize); // cleanup
};
}, []); // set up once, tear down once, on unmount
return <p>Window width: {width}px</p>;
}
React calls the cleanup function in two situations: right before the effect runs again (if dependencies changed), and when the component unmounts. This is what makes the pattern safe to reuse for anything periodic:
import { useEffect, useState } from "react";
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(intervalId); // clear the old interval before any re-run, and on unmount
}, []);
return <p>Elapsed: {seconds}s</p>;
}
Without the cleanup function here, every time this effect re-ran you'd stack up an additional interval still ticking in the background — a classic source of memory leaks and duplicated side effects in React apps.
Data fetching with loading and error states
Fetching data is the most common real-world use of useEffect. A production-quality fetch tracks at least three states: loading, error, and the data itself.
import { useEffect, useState } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // guards against updating state after unmount
async function loadUser() {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
const data = await response.json();
if (!cancelled) {
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadUser();
return () => {
cancelled = true; // if userId changes again before this finishes, ignore its result
};
}, [userId]);
if (loading) return <p>Loading user...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
A few details worth calling out:
useEffect's function cannot beasyncitself (an async function returns a Promise, not a cleanup function, which React would call incorrectly) — so the async logic is wrapped in an inner function that's declared and then immediately invoked instead.- The
cancelledflag prevents a classic race condition: ifuserIdchanges again while the first request is still in flight, the first request's result is discarded instead of overwriting the second, more relevant one when it eventually resolves. - All three states —
loading,error, anduser— are reset appropriately at the start of each fetch, so switching to a newuserIdshows a fresh loading state rather than briefly displaying the previous user's stale data.
In a real production app, this pattern is usually wrapped up by a data-fetching library like TanStack Query or SWR, which handles caching, retries, and race conditions like this automatically — but understanding the raw useEffect version is what makes those libraries make sense.
Common mistakes
- Omitting a value the effect actually uses from the dependency array, causing it to run with a stale, captured value instead of the current one.
- Returning nothing from an effect that sets up a listener, timer, or subscription — leaking it every time the effect re-runs or the component unmounts.
- Making the effect callback itself
async(useEffect(async () => {...}, [])) instead of defining an inner async function and calling it. - Forgetting the "is this still relevant" cancellation check in a fetch effect, letting an old, slow request overwrite a newer one's state.