React & Next.js

useEffect — synchronizing with external systems

useEffect isn't "run some code after render" in general — it's specifically for keeping a component in sync with something OUTSIDE React's own rendering: a subscription, a timer, the document title, a fetch request. Using it for anything else is the source of most real useEffect bugs.

Intermediate

4 min read

The problem: some things genuinely can't happen during render

function ProfilePage({ userId }) {
  document.title = `Profile: ${userId}`; // works, but is a side effect during render — risky
  return <div>...</div>;
}

Rendering is supposed to be a pure computation — given the same props/state, a component should describe the same UI, with no side effects escaping into the outside world along the way (React's concurrent rendering features, mentioned in the render-cycle lesson, specifically depend on render being safe to pause, retry, or discard without consequence). Directly mutating document.title during render technically works today, but it's exactly the kind of side effect that breaks those guarantees — useEffect exists to give side effects a genuinely correct, sanctioned place to run: after React has actually committed the render to the real DOM.

The basic shape

import { useEffect } from "react";
 
function ProfilePage({ userId }) {
  useEffect(() => {
    document.title = `Profile: ${userId}`;
  }, [userId]);
 
  return <div>...</div>;
}

useEffect(setupFunction, dependencyArray) runs setupFunction after React commits this render to the real DOM — never during render itself. The dependency array (covered in depth in the next lesson) controls exactly when the effect re-runs on subsequent renders; for now, the key fact is that this is where "do something to the outside world, in response to this component's data" belongs, structurally separated from the render function's own job of just describing UI.

The cleanup function: undoing what the effect set up

useEffect(() => {
  function handleResize() {
    console.log(window.innerWidth);
  }
  window.addEventListener("resize", handleResize);
 
  return () => {
    window.removeEventListener("resize", handleResize); // cleanup
  };
}, []);

If a useEffect function returns another function, React treats that returned function as cleanup — run right before the effect runs again (if it does), and run one final time when the component is removed from the screen entirely. This is the exact mechanism the events lesson referenced when it said React "handles removing listeners for you" — for a plain JSX onClick, React does this automatically; for a manually-attached listener (like window.addEventListener, which isn't a JSX prop at all), the cleanup function is your responsibility to write, and skipping it is a genuine, common source of memory leaks and duplicate listeners.

Data fetching: the classic real-world useEffect use case

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    let cancelled = false;
 
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) setUser(data); // guard against a stale response
      });
 
    return () => {
      cancelled = true; // if userId changes before this fetch resolves, ignore the old result
    };
  }, [userId]);
 
  return user ? <div>{user.name}</div> : <p>Loading...</p>;
}

Fetching data in response to a prop changing is a textbook "synchronize with something outside React" case — but it has a real, easy-to-miss race condition: if userId changes again before the first fetch resolves, the first fetch's .then() can still run and overwrite state with stale data for the wrong user. The cancelled flag, set by the cleanup function whenever the effect re-runs (because userId changed) or the component unmounts, is the standard pattern for guarding against exactly this — checking if (!cancelled) before actually applying a fetch's result to state.

What useEffect is NOT for — a common, real misuse

// WRONG — deriving one piece of state from another via an effect
function SearchResults({ query, allItems }) {
  const [filtered, setFiltered] = useState([]);
 
  useEffect(() => {
    setFiltered(allItems.filter((item) => item.includes(query)));
  }, [query, allItems]); // extra render every time this runs, plus real complexity for no reason
 
  return <ul>{filtered.map((item) => <li key={item}>{item}</li>)}</ul>;
}
 
// RIGHT — just compute it directly during render, no effect needed at all
function SearchResults({ query, allItems }) {
  const filtered = allItems.filter((item) => item.includes(query));
  return <ul>{filtered.map((item) => <li key={item}>{item}</li>)}</ul>;
}

If a value can be computed directly from existing props/state, computing it right there in the render function — no useEffect, no extra useState — is simpler, correct, and avoids the wasted extra render setFiltered would trigger (render once with stale filtered, then the effect fires, setFiltered schedules another render with the correct value). useEffect is specifically for synchronizing with something external to React's own data — a DOM API, a subscription, a network request, browser storage — not a general-purpose "run this after render" hook for logic that could just live in the render body itself.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. Why does useEffect exist, rather than just directly mutating things like document.title during render?

2. What does a function RETURNED from inside a useEffect callback actually do?

3. Why is computing a filtered list directly during render usually better than doing it inside a useEffect + setState?