Why Your useEffect Runs Twice and Why That Is Correct
React Strict Mode deliberately double invokes your effects in development. It is finding a real bug in your code, not creating one.
The short answer
React 18 and later, in development, with Strict Mode enabled, mount every component twice: mount, unmount, mount again. Your effect runs, its cleanup runs, then it runs again.
This does not happen in production. It is not a bug and you should not disable Strict Mode to make it stop.
It is a test. React is checking that your effect is resilient to being re-run, which is a real requirement because of Fast Refresh in development and, in the future, features that remount preserved state. If double invocation breaks something, that something was already broken and you had not noticed.
The fix is nearly always a cleanup function.
Tested on React 19.1.
What you are seeing
useEffect(() => {
console.log("effect ran");
fetchUser(id).then(setUser);
}, [id]);
Console shows effect ran twice. Network tab shows two identical requests. If the endpoint is not idempotent, you have created two records.
The sequence React runs in development:
render -> effect -> cleanup -> effect
In production it is just render -> effect.
Why React does this
The stated reason is to surface effects that are not idempotent, and the practical reason is Fast Refresh.
When you edit a file, Fast Refresh re-runs your component while preserving state. Your effect runs again. If your effect assumes it runs exactly once per component lifetime, it breaks in ways that look like mysterious dev-only bugs: duplicated event listeners, stacked intervals, subscriptions that never get released.
Strict Mode makes that failure happen immediately and consistently instead of occasionally when you happen to save a file.
The underlying rule React is enforcing: an effect plus its cleanup should be safe to run any number of times. Once you accept that as a design constraint, the double invocation stops being annoying and becomes a useful test.
The fixes by case
Subscriptions, listeners, intervals
These are the ones Strict Mode is really designed to catch, and the fix is the cleanup function that should always have been there.
// broken: two listeners after double invoke
useEffect(() => {
window.addEventListener("resize", onResize);
}, []);
// correct
useEffect(() => {
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
Same shape for setInterval and clearInterval, for WebSocket connect and close, for any observer and its disconnect.
If your effect has no cleanup and it sets up anything ongoing, that is a leak in production too. Strict Mode just showed it to you sooner.
Data fetching
The double request is usually harmless for a GET, since the second response overwrites the first. It becomes a real bug when responses arrive out of order.
// race condition: slow response for old id can overwrite fast response for new id
useEffect(() => {
fetchUser(id).then(setUser);
}, [id]);
If id changes from 1 to 2 quickly and the request for 1 resolves last, you display user 1's data while the state says 2. This has nothing to do with Strict Mode, and Strict Mode makes it likely enough to notice.
Use an abort controller:
useEffect(() => {
const ac = new AbortController();
fetchUser(id, { signal: ac.signal })
.then(setUser)
.catch(err => { if (err.name !== "AbortError") setError(err); });
return () => ac.abort();
}, [id]);
Or an ignore flag if the API does not support signals:
useEffect(() => {
let ignore = false;
fetchUser(id).then(data => { if (!ignore) setUser(data); });
return () => { ignore = true; };
}, [id]);
Honestly though, if you are fetching in an effect at all, a data library is the better answer. TanStack Query, SWR, or your framework's loader handle deduplication, caching, and cancellation, and they make the whole category disappear. Manual fetching in useEffect is one of those patterns that is fine in a tutorial and rarely right in an application.
Non idempotent side effects
// creates two records
useEffect(() => {
analytics.track("page_view", { page });
createDraft();
}, []);
Analytics double counting in development is annoying but harmless. createDraft() creating two drafts is a real bug.
The question to ask: should this run as a result of rendering at all?
Frequently the answer is no. Creating a draft is a user action, not a render consequence, so it belongs in an event handler. A large share of effect problems are effects that should not be effects. React's own documentation has a good section on this and it is worth reading.
Where it genuinely does belong in an effect, make the operation idempotent server side with an idempotency key, which you want anyway because the network will retry.
Refs to guard, and why to avoid it
You will find this suggestion everywhere:
const done = useRef(false);
useEffect(() => {
if (done.current) return;
done.current = true;
doThing();
}, []);
It works and it defeats the purpose. You have silenced the test rather than fixed the code, and the same effect will misbehave under Fast Refresh in a way this guard now hides.
I use this in one situation: a genuinely one-time non idempotent operation in a third party integration I cannot make idempotent. Everywhere else it is a smell.
Verify it is Strict Mode
Before you change anything, confirm what you are dealing with. Temporarily remove <StrictMode> from your root:
// main.jsx
createRoot(document.getElementById("root")).render(<App />);
If the double execution stops, it was Strict Mode. Put it back.
If it does not stop, you have a different problem: a parent re-rendering and remounting the child because of a changing key, a component defined inside another component's body so it is a new type each render, or a dependency array containing a value that is recreated every render.
That last one is the most common non-Strict-Mode cause:
// options is a new object every render, so the effect runs every render
const options = { limit: 10 };
useEffect(() => { fetchThings(options); }, [options]);
Fix by moving the object outside the component, or memoising it, or depending on primitives instead: [options.limit].
In Next.js
The App Router enables Strict Mode by default. You can turn it off in next.config.js and you should not.
Worth noting that in the App Router most data fetching belongs in Server Components, where there is no effect and no double invocation to worry about. If you are reaching for useEffect to fetch data in Next.js, check first whether the component needs to be a Client Component at all.
Also relevant: if your effect is compensating for something that differs between server and client render, you may actually be looking at a hydration mismatch rather than an effect problem.
The rule to take away
Write every effect so that running it, cleaning it up, and running it again produces the same state as running it once.
If you cannot, the logic probably does not belong in an effect.
Strict Mode is not being difficult. It is running the test that Fast Refresh and future React features will run anyway, and it is doing it in development where the cost of failing is zero.