React3 min read

Your effect runs twice in development, and that is the point

StrictMode mounts, unmounts, and mounts your component again. It is not a bug to work around. It is a test that finds effects with no cleanup, and here is how to pass it.

You add a fetch in useEffect. The network tab shows two requests. You add a log. It prints twice. Production only does it once, so the usual reaction is to reach for a ref that blocks the second run.

Do not. The second run is a test, and if you fail it your component has a real bug that shows up in production too.

What StrictMode actually does

In development, React mounts your component, runs the effect, runs the cleanup, and then runs the effect again. Three steps, on purpose.

The point is to simulate what happens when a component is mounted, unmounted, and mounted again. That happens in production all the time: a route change and back, a list that re-renders, a tab that closes and reopens. In React 18 and later it can also happen when state is restored.

If your effect works correctly for that sequence, it works. If it does not, you have a subscription that never gets removed, a timer that keeps running, or a request whose response arrives after the component is gone.

The bad fix

const didRun = useRef(false);

useEffect(() => {
  if (didRun.current) return;
  didRun.current = true;
  fetchData();
}, []);

This makes the symptom go away in development and keeps the bug. The effect still has no cleanup, so the real remount case, the one that happens to users, is still broken.

The good fix, three patterns

A subscription: return the unsubscribe.

useEffect(() => {
  const socket = createConnection(roomId);
  socket.connect();
  return () => socket.disconnect();
}, [roomId]);

Connect, disconnect, connect. The end state is one open connection, which is what you want.

A fetch: ignore the stale response.

useEffect(() => {
  let ignore = false;

  (async () => {
    const data = await getUser(userId);
    if (!ignore) setUser(data);
  })();

  return () => {
    ignore = true;
  };
}, [userId]);

The first request still finishes, but its result is thrown away. This also fixes a race that has nothing to do with StrictMode: switching from user 1 to user 2 while request 1 is still in flight, and having request 1 land last and overwrite user 2.

A fetch you want to cancel: AbortController.

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

  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((response) => response.json())
    .then(setUser)
    .catch((error) => {
      if (error.name !== "AbortError") setError(error);
    });

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

Do not forget the AbortError check, or a cancelled request will show up as a failed one.

The case where the effect should not be there at all

Often the double run is a hint that the code does not belong in an effect.

  • Analytics on page view: an effect is right, and running twice in development is harmless.
  • Sending a POST because the user clicked something: that belongs in the event handler, not an effect. It was never about the render.
  • Computing a value from props or state: no effect at all. Calculate it during render.

That last one is the most common. If you find yourself writing an effect that only calls setState, look at it again before you look at StrictMode.

Two things people get wrong about it

StrictMode only does this in development. Your production build runs the effect once. Do not disable StrictMode to make numbers look right in a development console.

And it is not only effects. React also calls your component function, your state initialiser, and your reducer twice in development, to find code that mutates something instead of returning new values. If a double render breaks your component, you are mutating something you should be copying.

Written by Rahul Mourya, full stack engineer. Spotted a mistake or want the follow-up post? Email me.

All posts
  • React3 min read

    The search box that shows results for the wrong query

    Type fast and a slow earlier request lands last, overwriting newer results. The fix is four lines. The better fix is not fetching in an effect at all.

    • data-fetching
    • effects
    • race-conditions
  • React3 min read

    Your useCallback is doing nothing without memo

    Wrapping every function in useCallback does not stop re-renders. It only helps when the child is memoised and every other prop is stable. Here is how to tell, and what to do instead.

    • performance
    • hooks
    • memoization
  • React2 min read

    Delete the effect that copies props into state

    Syncing state with useEffect gives you an extra render, a flash of stale data, and two sources of truth. Most of the time you can calculate the value during render instead.

    • state
    • effects
    • rendering