open atlas
↑ Back to track
React, zero to senior RCT · 02 · 01

useEffect is synchronization, not lifecycle

useEffect synchronizes a component with an external system — not lifecycle. Deps compare per element with Object.is; cleanup runs before every re-run and on unmount. StrictMode double-mounts to expose missing cleanup; fetch races need an ignore flag or AbortController.

RCT Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

The search page shipped on Thursday. On Friday a support ticket arrived with a screen recording: the user types “invoice”, pauses, finishes typing “invoices overdue” — and the results list shows matches for “invoice”. They retype it; same thing. The bug only reproduced on a slow connection, which is why every engineer on fast office Wi-Fi had marked it “cannot reproduce” twice. The effect fetched on every keystroke, but the network does not promise to answer in order: the request for “invoice” took 900ms, the request for “invoices overdue” took 200ms, and the slow stale response landed last, overwriting the fresh one with setResults. There was no cleanup function — nothing told the old request “your render is gone, stand down”. The same component had a second latent bug nobody had noticed: in development, StrictMode mounted it twice and two WebSocket connections piled up, and the team had “fixed” that by turning StrictMode off. Both bugs are the same bug: treating useEffect as “code that runs on mount” instead of a synchronization contract with a start and a stop.

Synchronization, not lifecycle

The mental model that produces bugs is the class-component one: “useEffect with [] is componentDidMount, useEffect with deps is componentDidUpdate”. The model that produces correct code is different: an effect describes how to keep an external system synchronized with the component’s current props and state. External system means anything outside React’s render output — a network request, a WebSocket, a setInterval, a DOM event listener on window, an analytics SDK, a third-party widget. The effect body says how to start synchronizing; the returned cleanup function says how to stop. React then runs that pair as many times as it needs: once per “the values this effect reads have changed”, not once per lifecycle phase you imagined.

Two mechanical facts follow. First, effects run after commit — React renders, mutates the DOM, the browser paints, and then your effect fires. The effect never blocks the render it belongs to. Second, each render owns its own effect closure. When query is “invoice”, the effect that runs has captured "invoice"; on the next render with “invoices overdue”, React doesn’t mutate the old effect — it tears it down (cleanup) and runs a brand-new one that captured the new value. There is no single long-lived effect “seeing” changing values; there is a sequence of short-lived effects, each frozen over the render that created it. Stale-closure bugs are exactly what happens when you fight this — when you tell React, via the deps array, that an effect doesn’t read a value it actually reads.

The dependency array, element by element

The deps array is not a configuration knob; it is a declaration: “this effect reads these reactive values”. On every render after the first, React compares the new array to the previous one per element, with Object.is. Any element differs → cleanup the old effect, run the new one. All equal → skip. Object.is is reference comparison for objects, so this code re-runs the effect on every single render:

function Chart({ series }) {
  // ❌ options is a new object identity every render,
  // so Object.is(prevOptions, options) is false every time —
  // the effect tears down and re-creates the chart on each render.
  const options = { animate: true, series };

  useEffect(() => {
    const chart = mountChart(node, options);
    return () => chart.destroy();
  }, [options]);
}

The fix is to depend on the primitives inside ([series]), construct the object inside the effect, or memoize it. The inverse failure is worse: lying to the linter. Omitting query from deps because “I only want this on mount” doesn’t make the effect read a stable value — it makes the effect keep running with the first render’s query forever. The exhaustive-deps lint rule is not a style preference; every suppression of it is a stale closure waiting for a reproduction case. If you don’t want the effect to re-run when a value changes, the honest answers are: move the value out of the component, make it a ref if it’s genuinely non-reactive, or restructure so the effect doesn’t read it. An empty array [] is a claim — “this effect reads no reactive values” — not a request for mount-only behavior.

Quiz

An effect has deps [filters] where filters is an object literal created in the component body each render. The component re-renders 30 times while the user scrolls. How many times does the effect run?

Cleanup, and why StrictMode mounts you twice

Cleanup timing is precise and worth memorizing: the cleanup from render N runs before the effect from render N+1, and once more at unmount. So the lifetime of one effect instance is bracketed: setup with render N’s values → (later) teardown with render N’s values. The teardown closure sees the old props, which is exactly what you want — it’s the old subscription, the old room id, the old request that needs cancelling. An effect is correct when setup and cleanup are symmetrical: connect/disconnect, subscribe/unsubscribe, start/abort. If you can’t write the cleanup, that’s usually a sign the effect is doing something that isn’t synchronization (computing derived state, responding to “events”) and belongs elsewhere.

This is what StrictMode’s development-only double-mount is for. Since React 18, StrictMode mounts every component, runs effects, immediately runs cleanup, then mounts again. It is a correctness probe: if your effect is a symmetrical setup/cleanup pair, the extra cycle is invisible. If mounting twice doubles your WebSocket connections, fires analytics twice, or leaks an interval — StrictMode didn’t create that bug, it surfaced it, because the same teardown/setup cycle happens in production whenever deps change, when the component remounts during navigation, and under fast-refresh in dev. Turning StrictMode off (the Hook’s team did exactly this) doesn’t fix the asymmetry; it removes the only thing telling you about it.

Why this works

Why would React deliberately mount everything twice in development? Because “runs once on mount” was never a guarantee the programming model could keep, and the React team needed code to stop depending on it. Reusable state — preserving a component’s state when it’s removed and re-added, e.g. back-navigation restoring a screen instantly — requires React to be able to unmount and remount components with effects firing again. Any effect that breaks under mount→cleanup→mount would break under those features too, and breaks today under deps-driven re-runs. The double-mount is a cheap dev-time fuzz test for the invariant the model always demanded: effects must be safe to run any number of times, as long as each run is paired with its cleanup. The fix is never to count invocations (the didInit ref hack); it’s to make setup and teardown symmetrical so the count doesn’t matter.

Fetch races: the bug every dashboard ships once

Data fetching in an effect is synchronization with a remote system, and the remote system answers out of order. When you see a search or autocomplete field fetching on every keystroke, ask yourself: what happens if the slow request lands after the fast one? The Hook’s bug in code: effect for "invoice" starts a 900ms request; deps change; effect for "invoices overdue" starts a 200ms request and resolves, painting fresh results; then the stale 900ms response lands and calls setResults with old data. Last write wins, and the last writer was the obsolete request. Nothing crashes, no error logs — just wrong data on screen, with a probability proportional to network jitter, which is why it surfaces in production and “cannot reproduce” in the office.

The cleanup function is the cancellation point. Two standard patterns, often combined:

useEffect(() => {
  const controller = new AbortController();
  let ignore = false; // belt and suspenders: guards non-abortable async steps

  fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: controller.signal,
  })
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setResults(data); // stale closure checks ITS OWN flag
    })
    .catch((err) => {
      if (err.name !== "AbortError") setError(err);
    });

  return () => {
    ignore = true;        // this render's flag — only this effect's responses are muted
    controller.abort();   // actually cancels the network request
  };
}, [query]);

The ignore flag works because each effect closure has its own flag: cleanup for the “invoice” effect flips the “invoice” flag, and only the “invoice” response checks it. AbortController goes further — it cancels the request at the network layer, freeing the connection and letting the server stop work; the rejected promise must swallow AbortError specifically, or every keystroke logs a phantom error. The honest production answer is usually “don’t hand-roll this”: query libraries and router loaders implement cancellation, deduplication, and caching on top of exactly this mechanism. But you must be able to write the raw version, because every wrapper’s semantics — and every wrapper’s bugs — reduce to this effect.

Quiz

In development with StrictMode, your chat component opens two WebSocket connections on mount. In production it opens one. What is the correct reading of this?

Diagnose and fix a stale-fetch race in useEffect

1/3
Recall before you leave
  1. 01
    Explain how the dependency array is compared and the two classic ways teams break it.
  2. 02
    Why does StrictMode mount components twice in development, and what does a failure under it mean?
Recap

useEffect is not a lifecycle hook with mount and update phases; it is a contract for synchronizing the component with an external system — network, sockets, timers, DOM listeners, third-party SDKs. The effect body starts the synchronization, the returned function stops it, and React runs that pair as many times as the data demands. Effects run after commit and paint, and each render owns its own effect closure frozen over that render’s props and state — there is no single long-lived effect watching values change, only a sequence of short-lived ones. The deps array declares which reactive values the effect reads; React compares it per element with Object.is, so an inline object or function is a guaranteed re-run every render, and omitting a value the effect actually reads is a stale closure — the exhaustive-deps rule is a correctness check, not style. Cleanup from render N runs before the effect from render N+1 and once more at unmount, deliberately closing over the old values, because it is the old subscription or request being torn down. StrictMode’s dev-only double-mount (mount, cleanup, mount) is a probe for this symmetry: if two mounts double your connections, the cleanup is missing or asymmetric, and the same leak fires in production on every deps change or remount — disable StrictMode and you keep the bug while losing the detector. Data fetching inherits all of this plus network reordering: a slow stale response can land after a fast fresh one and win the last write, so the cleanup must cancel — an ignore flag per effect closure mutes stale responses, AbortController cancels the request at the network layer (swallow AbortError in the catch), and production code usually delegates this to a query library built on exactly these primitives. Now when you see an effect without a cleanup function making a network request, you know exactly which race it is shipping to production.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.