The most useful mental model for useEffect isn't "component lifecycle" — it's "synchronize this component with something outside React" (the DOM, a subscription, a timer, fetch, localStorage). Anything that isn't purely "compute output from props/state" belongs in an effect, not directly in the render body.
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // re-run this effect only when `count` changes
The dependency array controls when the effect re-runs, not whether it runs at all:
| Dependency array | Behavior |
|---|---|
| Omitted entirely | Runs after every render |
[] | Runs once, after the first render only |
[count] | Runs after the first render, and again after any render where count changed |
An effect can return a function — React calls it right before the effect runs again, and once more when the component unmounts:
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); // cleanup
}, []);
Without the cleanup function here, every re-mount of this component would add another listener that's never removed — a real memory leak in a long-lived app (e.g., a component that mounts and unmounts repeatedly inside a tab switcher or modal).
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // always logs the count from when the effect ran
}, 1000);
return () => clearInterval(interval);
}, []); // empty array — this effect's closure over `count` is created exactly once
With an empty dependency array, the effect's callback captures count from the very first render and never sees it change — the effect itself never re-runs to create a fresh closure with a fresh count. Two ways to actually fix it, depending on intent:
// Fix 1: put count in the dependency array — the interval is torn down
// and recreated (with a fresh closure) every time count changes.
useEffect(() => {
const interval = setInterval(() => console.log(count), 1000);
return () => clearInterval(interval);
}, [count]);
// Fix 2: if you don't actually need to depend on count changing, use the
// functional updater form to read the latest value without depending on it.
useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1); // always correct, no dependency on count needed
}, 1000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
let cancelled = false;
async function load() {
const res = await fetch(`/api/users/${userId}`);
const data = await res.json();
if (!cancelled) setUser(data); // guard against a stale response
}
load();
return () => { cancelled = true; };
}, [userId]);
If userId changes quickly (the user clicks between two profiles fast), the first fetch can resolve after the second one, overwriting fresh data with stale data — a real race condition, not a hypothetical one. The cancelled flag (or an AbortController, which additionally cancels the in-flight network request) discards a response that arrives after its effect has already been superseded. Frameworks and data-fetching libraries (React Query, SWR, Next.js's built-in data fetching) exist largely to handle this correctly by default, which is why hand-rolled fetch-in-useEffect is increasingly rare in real production code — but understanding why it needs this guard is exactly what interviewers probe for.