Skip to the solution
HardFrontend7 min read

Why does React 18 StrictMode double-invoke your components and effects?

908 views

The problem

Question presented to candidate: "Your console log appears twice on mount and your effect fires twice. What is going on and should you fix it?"

What a strong answer should cover:

  • StrictMode deliberately runs things twice in development to surface two classes of bug: impure render and incomplete effect cleanup.
  • What is double-invoked: the component function body, useState/useReducer initialisers and updater functions, and useMemo/useCallback factories β€” everything that is supposed to be pure. If running it twice changes the result, it was not pure.
  • Effects are treated differently: React mounts, unmounts, then mounts again β€” so you see effect, cleanup, effect. That tests whether your cleanup fully undoes the setup.
  • Why it matters beyond development: React needs to be able to discard and restart a render for concurrent features, and to remount a component with preserved state. Code that breaks under the double-invoke would break there too.
  • It is development-only. Production runs each once, so this is not a performance concern.
  • The wrong fix is a ref guard to make the effect run once. That silences the alarm and leaves the fire β€” the effect is not idempotent, and it will misbehave the first time the component genuinely remounts.
  • The right fix is a cleanup that fully reverses the setup: abort the request, unsubscribe, clear the timer.

Clarifying questions expected:

  • "Is this development or production?" β€” the answer differs entirely.
  • "Does the doubled run actually cause a problem?" β€” if yes, that is a real bug.

Code / implementation expected: Optional. An effect that breaks under the double-invoke and its fixed version is the clearest demonstration.

  • react
  • strict-mode
  • effects

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactAn effect that breaks under the double-invoke, and the cleanup that fixes it
import { useState, useEffect, useRef, StrictMode } from "react";

// A module-level counter, so we can see side effects that actually escaped.
const stats = { started: 0, completed: 0, applied: 0 };

const fakeFetch = (id, signal) =>
  new Promise((resolve, reject) => {
    stats.started++;
    // Slower for the first id, so an out-of-order resolve is visible.
    const ms = id === 1 ? 700 : 200;
    const t = setTimeout(() => { stats.completed++; resolve("data for #" + id); }, ms);
    if (signal) {
      signal.addEventListener("abort", () => {
        clearTimeout(t);
        const err = new Error("aborted");
        err.name = "AbortError";
        reject(err);
      });
    }
  });

// ❌ No cleanup. Under StrictMode it fires twice, and switching ids fast lets
//    a slow earlier request overwrite a fast later one.
function Naive({ id, onRender }) {
  const [data, setData] = useState("loading…");
  onRender();
  useEffect(() => {
    setData("loading…");
    fakeFetch(id).then((d) => { stats.applied++; setData(d); });
  }, [id]);
  return <Row label="❌ no cleanup" value={data} />;
}

// ❌ The tempting "fix": a ref guard. The doubled call goes away β€” and so does
//    any refetch when the component genuinely remounts or the id changes.
function Guarded({ id, onRender }) {
  const [data, setData] = useState("loading…");
  const done = useRef(false);
  onRender();
  useEffect(() => {
    if (done.current) return;
    done.current = true;
    fakeFetch(id).then((d) => { stats.applied++; setData(d); });
  }, [id]);
  return <Row label="❌ ref guard" value={data} />;
}

// βœ… A cleanup that fully reverses the setup. Safe to run twice, and it fixes
//    the out-of-order race that existed with or without StrictMode.
function Correct({ id, onRender }) {
  const [data, setData] = useState("loading…");
  onRender();
  useEffect(() => {
    const controller = new AbortController();
    setData("loading…");
    fakeFetch(id, controller.signal)
      .then((d) => { stats.applied++; setData(d); })
      .catch((e) => { if (e.name !== "AbortError") setData("error"); });
    return () => controller.abort();
  }, [id]);
  return <Row label="βœ… abort on cleanup" value={data} />;
}

const Row = ({ label, value }) => (
  <div style={{ fontSize: 13, padding: "3px 0" }}>
    <code style={{ display: "inline-block", minWidth: 170 }}>{label}</code>
    {value}
  </div>
);

export default function App() {
  const [strict, setStrict] = useState(true);
  const [id, setId] = useState(1);
  const renders = useRef(0);
  const [, tick] = useState(0);

  const body = (
    <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12 }}>
      <Naive id={id} onRender={() => { renders.current++; }} />
      <Guarded id={id} onRender={() => {}} />
      <Correct id={id} onRender={() => {}} />
    </div>
  );

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 560 }}>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
        <button onClick={() => {
          stats.started = stats.completed = stats.applied = 0;
          renders.current = 0;
          setStrict((s) => !s);
        }}>
          StrictMode: {strict ? "ON" : "off"}
        </button>
        <button onClick={() => setId(id === 1 ? 2 : 1)}>switch to #{id === 1 ? 2 : 1}</button>
        <button onClick={() => tick((n) => n + 1)}>refresh counts</button>
      </div>

      {strict ? <StrictMode key="s">{body}</StrictMode> : <div key="n">{body}</div>}

      <p style={{ fontSize: 13, color: "#666", marginTop: 10 }}>
        requests started: <strong>{stats.started}</strong> Β·
        {" "}results applied to state: <strong>{stats.applied}</strong> Β·
        {" "}Naive body ran: <strong>{renders.current}</strong>
        {" "}(press refresh β€” these are module counters, not state)
      </p>

      <p style={{ fontSize: 13, color: "#666" }}>
        Toggle StrictMode and press refresh: the naive row doubles its requests.
        Now switch ids quickly β€” the naive row can settle on the WRONG id
        because a slow earlier request lands last, and the guarded row never
        refetches at all. Only the aborting version is correct in both cases,
        which is the real lesson: StrictMode did not create these bugs.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 81 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

How does streaming SSR with selective hydration work?

Continue