CodeOath
← All posts
React75 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Contents — Part 8 of 17: useEffect In Depth
Part 8 of 17 · ~3 min

useEffect In Depth

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 arrayBehavior
Omitted entirelyRuns after every render
[]Runs once, after the first render only
[count]Runs after the first render, and again after any render where count changed

Cleanup functions

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).

The stale closure bug

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);
}, []);

Data fetching in an effect, and the race condition it hides

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.