ReactAn effect that breaks under the double-invoke, and the cleanup that fixes it
import { useState, useEffect, useRef, StrictMode } from "react";
const stats = { started: 0, completed: 0, applied: 0 };
const fakeFetch = (id, signal) =>
new Promise((resolve, reject) => {
stats.started++;
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);
});
}
});
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} />;
}
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} />;
}
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>
);
}