Ch. 3 · React

React StrictMode and Double-Invoked Effects

What StrictMode calls twice in development, the exact mount, unmount, mount sequence in React 19, and how to fix the effects it exposes.

~6 min readintermediateupdated Oct 19, 2026

Sooner or later every React developer adds a console.log to an effect and sees it twice. The next search usually turns up advice to remove <StrictMode> from main.jsx, and the log goes back to once. That makes the symptom disappear and leaves the bug in place, because StrictMode was pointing at something real.

This topic comes up in interviews because the answer separates people who know how effects are supposed to work from people who’ve only made them work. You need to know exactly what is doubled, why, and what a correct fix looks like.

What StrictMode calls twice

<StrictMode> affects only development builds. Inside it, React deliberately repeats work that should be safe to repeat:

  • Component functions are called twice per render. So are useState and useReducer initializer functions, reducers, useMemo and useCallback calculations, and state updater functions you pass to setState. React keeps one result and discards the other, so a pure function behaves identically. An impure one, say a component that pushes to a module-level array during render, shows its impurity as a doubled side effect.
  • Effects run setup, then cleanup, then setup again when a component first mounts. This simulates the component being unmounted and remounted, which happens in real apps: React’s <Activity> (offscreen) feature, fast refresh during development and future features that preserve state while hiding UI all depend on components surviving it.
  • Ref callbacks (in React 19) are attached, detached and attached again on mount, for the same reason.

React 19 refined the render doubling: during the second call, useMemo and useCallback reuse the result from the first call instead of computing again.

Watching it happen

Here’s a chat component with an effect that connects to a room and never disconnects:

function ChatRoom({ room }) {
  console.log('render');
  const [draft] = useState(() => { console.log('useState initializer'); return ''; });
  useMemo(() => console.log('useMemo calc'), [room]);

  useEffect(() => {
    console.log('effect: setup');
    connect(room); // opens a socket, returns a connection
  }, [room]);

  return <p ref={(node) => console.log(node ? 'ref attach' : 'ref detach')}>{room}</p>;
}
JSX

Rendered inside <StrictMode> with React 19 in development, the mount produced this sequence:

render | useState initializer | useState initializer | useMemo calc | useMemo calc | render |
ref attach | effect: setup | connect #1 | ref detach | ref attach | effect: setup | connect #2
Text

And at the end, two open connections. The same code in a production build:

render | useState initializer | useMemo calc | ref attach | effect: setup | connect #1
Text

One connection. So in production this component “works”, until a user switches rooms. Then room changes, the effect runs again for the new room, the old connection is never closed, and messages from the previous room keep arriving. StrictMode showed you, on the first mount, a leak that production would have shown only after the user did something.

The fix: make setup and cleanup symmetrical

The effect is missing the code that undoes it. Return a cleanup:

useEffect(() => {
  const connection = connect(room);
  return () => connection.disconnect();
}, [room]);
JSX

Now the development mount logs connect, disconnect, connect, and leaves exactly one open connection, and a room change disconnects the old room before connecting to the new one. That’s the test StrictMode applies: if setup, cleanup, setup leaves the world in the same state as a single setup, the effect is correct.

The same reasoning covers the common cases:

  • Subscriptions and listeners: addEventListener pairs with removeEventListener, subscribe with unsubscribe, setInterval with clearInterval.
  • Fetching: you can’t un-send a request, but you can ignore its result. Use an AbortController and call abort() in the cleanup, or set an ignore flag that the .then checks. In development you’ll see two requests in the network tab, one cancelled; that’s fine, and the same code prevents race conditions when the dependency changes quickly.
  • Animations: reset the element to its initial state in cleanup, or the second setup starts from where the first left it.
  • Third-party widgets: call the widget’s destroy() method in cleanup. If a library has no way to tear down, it doesn’t fit inside an effect without a wrapper.

The fixes that aren’t fixes

Removing StrictMode. It silences the development check, nothing more. The leak above still happens on every room change in production.

A “has run” ref.

const didConnect = useRef(false);
useEffect(() => {
  if (didConnect.current) return;
  didConnect.current = true;
  connect(room);
}, [room]);
JSX

This hides the double setup, but now the effect also ignores real changes to room: switch rooms and nothing reconnects. It also breaks under any real unmount and remount.

Moving the work to useLayoutEffect. Layout effects are doubled too. Changing the hook changes timing, not the StrictMode check.

Work that genuinely should happen once

Some things should run once per application load, not once per component mount: initialising an analytics SDK, reading a token from storage, registering a service worker. They don’t belong in an effect at all. Put them at module level, or in a function guarded by a module-level variable:

let initialised = false;
export function initAnalytics() {
  if (initialised) return;
  initialised = true;
  analytics.init({ key: import.meta.env.VITE_ANALYTICS_KEY });
}
JavaScript

and call it from your entry file before createRoot(...).render(...). That’s different from the ref trick above: the guard reflects a genuine “once per page” rule, not a workaround for an effect.

Work caused by a user action, such as sending a “purchase completed” event or posting a form, belongs in the event handler, not in an effect that watches state. Effects that fire network writes in response to state changes are a frequent source of duplicate orders and analytics events, and StrictMode makes them visible by duplicating the effect.

Impure renders

The render doubling catches a different class of bug. A component that mutates something during render, for example:

function Row({ item, seen }) {
  seen.push(item.id); // mutating a prop during render
  return <li>{item.name}</li>;
}
JSX

pushes each ID twice in development. React expects render to be a pure calculation from props and state; it may call it more than once, or start a render and throw it away, in concurrent features like transitions. Move the side effect to an event handler or an effect, or derive the value instead of collecting it.

Interview exercise: the duplicate analytics event

“Our product page logs a product_viewed event in a useEffect with an empty dependency array. In development, every view is logged twice. A developer proposes wrapping the app in StrictMode only in tests. What do you say?”

Answer. The double log is StrictMode simulating an unmount and remount on first mount, and turning it off doesn’t make the effect correct. In production the same effect would log again if the page component remounts, for example when a parent changes a key, a route layout is re-created, or a future feature restores hidden UI. I’d first decide what “a view” means. If it means “the product page was shown for this product ID”, I’d key the effect on the product ID and deduplicate in the analytics layer by product ID and a page-view identifier, so a remount doesn’t create a second event for the same view. If it means “once per page load”, it shouldn’t be in a component effect at all; it belongs in the router’s route-change callback or a module-level function. Either way StrictMode stays on, because the doubled log was the early warning, and seeing two events in development is expected as long as the analytics layer deduplicates them.

Keep reading

More in React

esc