Skip to solution
hardFrontend

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

908 views
01

Understand 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.

reactstrict-modeeffects
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes effects and cleanup. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The counts in section 3 were executed against React 19.2.8 by rendering the

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

An effect that breaks under the double-invoke, and the cleanup that fixes it
Run Playground
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>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 81 of 119 decoded in the React.js track. One more won't hurt.

Back to track