React 18 Strict Mode: Why You See Duplicate Requests (and How to Fix Them)
You ship a clean data-fetching hook. In development everything looks fine… until you notice the network tab: two identical requests for the same endpoint.
This is one of the most common “frontend issues” I see teams trip over after upgrading to React 18 or enabling Strict Mode.
This post shows:
- what’s actually happening,
- what not to do,
- a few fixes that are safe in real apps,
- and how to verify you didn’t just hide the symptom.
The symptom
You have something like this:
import { useEffect, useState } from "react";
type User = { id: string; name: string };
export function useUser(userId: string) {
const [data, setData] = useState<User | null>(null);
useEffect(() => {
let ignore = false;
async function load() {
const res = await fetch(`/api/users/${userId}`);
const json = (await res.json()) as User;
if (!ignore) setData(json);
}
load();
return () => {
ignore = true;
};
}, [userId]);
return { data };
}
In dev, you might see:
/api/users/123requested twice- logs printed twice
- analytics events fired twice
- side effects “re-run” unexpectedly
The root cause (React 18 + Strict Mode)
In React 18, Strict Mode intentionally double-invokes some lifecycle behavior in development to help you detect unsafe side effects. One of the effects: components mount, run effects, unmount, then mount again — which can trigger your “fetch on mount” code twice.
Important notes:
- This is development-only behavior (not production) when Strict Mode is enabled.
- It’s not “a bug you need to hack around”; it’s a signal that your side effects must be resilient.
What not to do
Don’t disable Strict Mode to “fix” it
Strict Mode is catching classes of problems that will show up in other ways (race conditions, stale updates, flaky UI).
Don’t rely on “ignore flags” alone
The ignore flag prevents setting state after unmount, but it does not stop the extra request (and it won’t protect you from duplicated analytics).
Fix option A: cancel the request with AbortController (recommended baseline)
If your request becomes irrelevant (component unmounts or userId changes), abort it:
import { useEffect, useState } from "react";
type User = { id: string; name: string };
export function useUser(userId: string) {
const [data, setData] = useState<User | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
try {
setError(null);
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const json = (await res.json()) as User;
setData(json);
} catch (e) {
if ((e as { name?: string }).name === "AbortError") return;
setError(e as Error);
}
}
load();
return () => controller.abort();
}, [userId]);
return { data, error };
}
This doesn’t magically “dedupe” the fetch, but it makes your effect safe and prevents stale updates. It’s table stakes.
Fix option B: dedupe requests (cache by key)
If you don’t want duplicate work in dev (or you have multiple components requesting the same resource), add a simple module-level cache.
This is basically what libraries like React Query / SWR do for you, but here’s a minimal pattern:
const inflight = new Map<string, Promise<unknown>>();
function fetchOnce<T>(key: string, fn: () => Promise<T>) {
const existing = inflight.get(key) as Promise<T> | undefined;
if (existing) return existing;
const promise = fn().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
Then in your effect:
useEffect(() => {
const controller = new AbortController();
fetchOnce(`user:${userId}`, () =>
fetch(`/api/users/${userId}`, { signal: controller.signal }).then((r) =>
r.json()
)
).then(setData, (e) => {
if ((e as { name?: string }).name === "AbortError") return;
setError(e as Error);
});
return () => controller.abort();
}, [userId]);
If your app is non-trivial, reach for a proven solution (React Query / SWR). The point is the idea: requests should be safe and shareable.
How to verify the fix
Don’t just watch the network tab once and call it done.
- Simulate slow networks (DevTools throttling) and ensure UI doesn’t flash stale data.
- Change parameters quickly (switch
userIdrapidly) and ensure old responses don’t win races. - Check for duplicate analytics (events should not fire twice).