Trust is earned, not given

A different perspective

2022-08-09 · Projects

React.js, part 2: why your effect runs twice in development (and what StrictMode is really telling you)

Part 2from the React.js series · 20 parts in all

Every developer meets it the same way: a console.log inside useEffect fires twice, or a websocket opens two connections, and the first instinct is a bug in React. It is not. In React 18 the development build mounts, unmounts, and remounts every component, on purpose. Part 2 explains what that is testing and how to make your effects pass.

The double invocation is a lint for missing cleanup

React is checking one property: is your component resilient to being torn down and set back up? Components can be removed and restored for reasons that do not involve you — a Suspense boundary re-showing a child, an offscreen tree being parked. If an effect's setup adds something the cleanup does not remove, the second mount exposes it as a duplicate.

useEffect(() => {
  const socket = new WebSocket('wss://example.com/feed');
  // Symmetric cleanup: StrictMode mounts, unmounts, then mounts again,
  // so without this close() you would leak the first socket.
  return () => socket.close();
}, []);

The other half: aborted fetches, not cancelled flags

The classic "is mounted" boolean is the anti-pattern. The cleanup should actually cancel the work, which is what AbortController is for:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/items?q=${query}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setItems)
    .catch(err => {
      if (err.name === 'AbortError') return;   // expected on cleanup
      setError(err);
    });

  return () => controller.abort();
}, [query]);

With a real cancellation the effect is safe under any number of mounts, and you get the production benefit too: a fast-changing query stops firing stale requests.

What StrictMode deliberately does not do

It changes nothing in production, and it does not unmount your tree to save memory. The double-render applies only in development, only to components under <StrictMode>. The reason to keep it on is simple: it finds the effects that only break in the field — the leaked socket, the timer that fires after unmount, the subscription that doubles on every navigation.

Two habits follow. First, effects should be self-contained: acquire, then release. Second, prefer data-fetching libraries that already implement cancellation rather than reinventing it. Next: keeping the UI responsive with Suspense and useTransition.