🪴 GoDeep
Search
← Shipping Software

Effects, and when not to use them

Learn what `useEffect` is genuinely for — synchronising your component with something outside React — how cleanup and dependency arrays work, why effects run twice in development, and the four cases where an agent reaches for an effect and shouldn't.

12 cards · 6 quiz questions · 11 min read

useEffect is where agent-written React goes wrong most often, and most expensively. It looks like a general-purpose “run this code” hook, so it accumulates work that belongs elsewhere: computing totals, resetting filters, submitting forms, fetching data. The result is dashboards that flash stale numbers, duplicate export jobs, and tables showing the previous study’s rows. React’s own documentation has a page titled You Might Not Need an Effect, which tells you how common this is.

What an effect is for

An effect lets you synchronise your component with a system outside React: a WebSocket, a browser API, a third-party chart library, an analytics endpoint, a timer. React’s framing is worth memorising — effects are for code that must run because the component is displayed, not because the user did something.

Effects run after React has committed the render to the DOM and the browser has painted. Never during rendering.

// A legitimate effect: a live connection that exists while the panel is on screen
function JobProgress({ jobId }: { jobId: string }) {
  const [percent, setPercent] = useState(0);

  useEffect(() => {
    const socket = new WebSocket(`wss://api.iff.internal/jobs/${jobId}/progress`);
    socket.onmessage = (e) => setPercent(JSON.parse(e.data).percent);
    return () => socket.close();   // cleanup: stop synchronising
  }, [jobId]);                     // re-synchronise when the job changes

  return <ProgressBar value={percent} />;
}

Three parts do all the work: the setup (open the socket), the cleanup (close it), and the dependency array (which values, when changed, mean “stop and start again”).

Cleanup

The cleanup function is the returned function. React calls it before every re-run of the effect, and once more when the component unmounts. The discipline is symmetry: for every subscribe, an unsubscribe in the same effect.

Setup doesCleanup must do
new WebSocket(...)socket.close()
setInterval / setTimeoutclearInterval / clearTimeout
addEventListenerremoveEventListener with the same function reference
fetch(url, { signal })controller.abort()
Create a chart or map instanceDestroy it

A missing cleanup rarely fails loudly. It leaks: connections pile up as the user navigates, an interval keeps calling setState on an unmounted component, three copies of a resize listener recalculate a layout. The symptom is a tab that gets slower the longer it’s open.

// Before: a new listener on every render, none removed
useEffect(() => {
  window.addEventListener("resize", () => setWidth(window.innerWidth));
});

// After: one listener, removed by reference, set up once
useEffect(() => {
  const onResize = () => setWidth(window.innerWidth);
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

Note the first version has no dependency array at all. That means “after every render” — and since the effect sets state, it re-renders, which re-runs the effect. That’s an infinite loop, and it’s one of the two ways an agent hangs a browser tab.

Dependency arrays

Three forms, three meanings.

  • No array: runs after every render. Almost always a mistake, and a loop if the effect sets state.
  • []: runs setup on mount, cleanup on unmount. Correct only if the effect genuinely reads nothing reactive — and the source of most stale-closure bugs when it isn’t.
  • [a, b]: re-runs whenever a or b changes, compared by identity with Object.is.

Identity is the trap. Objects, arrays and functions created during render are brand new every time, so listing one is the same as having no array:

// Before: `filters` is a fresh object literal each render → effect runs forever
const filters = { status, dateFrom };
useEffect(() => { refresh(filters); }, [filters]);

// After: depend on the primitive values React can actually compare
useEffect(() => { refresh({ status, dateFrom }); }, [status, dateFrom]);

Let eslint-plugin-react-hooks and its exhaustive-deps rule tell you what the dependencies are. An eslint-disable comment above the array doesn’t remove a dependency — it hides one, and the effect then runs with values frozen from an earlier render.

Why effects run twice in development

In development, Strict Mode mounts each component, immediately unmounts it, and mounts it again. So every effect runs setup, cleanup, setup. This does not happen in production builds.

It is a test, not a bug. An effect with correct cleanup is unaffected — the first socket is closed before the second opens. An effect that appends to an array, increments a server-side counter or sends an email reveals itself at once, on your machine, instead of in production when a user navigates back.

// Before: agent "fixes" the double-run by suppressing it
const didInit = useRef(false);
useEffect(() => {
  if (didInit.current) return;    // hides the symptom, keeps the bug
  didInit.current = true;
  connect();
}, []);

// After: make the effect restartable, which is what was actually wrong
useEffect(() => {
  const conn = connect();
  return () => conn.disconnect();
}, []);

React’s docs say plainly not to use a ref to prevent effects from firing. The ref guard fails the moment a real remount happens — a route change, a tab switch, a parent re-keying the component — and by then you’re debugging in production.

The four “you don’t need an effect” cases

1. Deriving state

If you can calculate it during render, you don’t need an effect.

// Before: an extra render pass, and a visible moment of stale numbers
const [completed, setCompleted] = useState(0);
useEffect(() => {
  setCompleted(rows.filter((r) => r.status === "complete").length);
}, [rows]);

// After
const completed = rows.filter((r) => r.status === "complete").length;

If the calculation is genuinely slow — measured, in a profile — wrap it in useMemo, not useEffect.

2. Resetting state when a prop changes

// Before: inside ResponsesPanel
useEffect(() => { setQuery(""); setPage(0); }, [studyId]);

// After: in the parent — a new key means a new component with fresh state
<ResponsesPanel key={studyId} studyId={studyId} />

To reset only part of the state, compare the prop with its previous value during render and call the setter there. React re-runs the component immediately, without painting the intermediate state.

3. Logic that belongs in an event handler

This is the one that causes real damage.

// Before: a POST that repeats on every remount — duplicate export jobs
const [requested, setRequested] = useState(false);
useEffect(() => {
  if (requested) fetch(`/api/studies/${studyId}/export`, { method: "POST" });
}, [requested, studyId]);

// After: it happens because the user clicked, so it lives in the click handler
async function onExportClick() {
  await fetch(`/api/studies/${studyId}/export`, { method: "POST" });
}

Ask why the code runs. “Because the user clicked Export” is an event. “Because this panel is on screen” is an effect. Anything that writes to your API, sends a message or charges money should almost never be in an effect.

4. Fetching data

You can fetch in an effect, but you must handle the race condition — and hand-written versions rarely do.

// Before: responses can arrive out of order; the slowest study wins
useEffect(() => {
  fetch(`/api/studies/${studyId}/responses`)
    .then((r) => r.json())
    .then(setRows);
}, [studyId]);

// After: cleanup ties the response to the render that asked for it
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/studies/${studyId}/responses`, { signal: controller.signal })
    .then((r) => r.json())
    .then(setRows)
    .catch((e) => { if (e.name !== "AbortError") setError(e); });
  return () => controller.abort();
}, [studyId]);

Even fixed, this version has no cache, no deduplication, no retry, no loading state shared between components, and it starts fetching only after the component renders. React’s own recommendation is to use your framework’s data loading, or a library such as TanStack Query or SWR. For a dashboard hitting a FastAPI back end, that one dependency removes a whole class of bug you’d otherwise rediscover per component.

And a fifth: chains of effects

An effect that sets state, waking an effect that sets more state, is a state machine written in the least inspectable way available. Each link is a separate render the user can see. Compute what you can during render, and do the rest in the single event that started it.

What to check in your own code

  • Grep for useEffect and, for each one, answer out loud: what outside-React system is this synchronising with? If there isn’t one, it probably shouldn’t be an effect.
  • Grep for useEffect calls with no second argument at all — every one runs after every render.
  • Grep for method: "POST", "PUT", "DELETE" and any email or export call, and check none sit inside an effect.
  • Grep for addEventListener, setInterval, new WebSocket and subscribe and confirm each has a matching cleanup in the same effect.
  • Grep for setState-style calls directly inside an effect whose dependency list contains what it’s deriving from — that’s derived state wearing a disguise.
  • Look for didInit, hasRun, isFirstRender and mountedRef — usually a Strict Mode double-run being suppressed rather than a restart bug being fixed.
  • Check that every fetch in an effect has either an AbortController or an ignore flag flipped in the cleanup.
  • Open the Network tab, click through three studies fast, and watch whether stale responses land after fresh ones.

Sources

  • React documentation — You Might Not Need an Effect website The canonical list of effects to remove: derived state, resetting on prop change, event-handler logic, chains of computations and data fetching.
  • React documentation — Synchronizing with Effects website Effects as synchronisation with external systems, the cleanup function, and how to handle the Effect firing twice in development.
  • React documentation — useEffect website Reference for the dependency array, cleanup semantics and the setup/cleanup lifecycle.
  • React documentation — Lifecycle of Reactive Effects website Why an effect's lifecycle is start/stop synchronising rather than mount/unmount, and what makes a value reactive.