How would you debug a React snippet where useEffect is not behaving as expected?
Common causes: missing dependency (stale closure, value never updates), wrong dep array shape ([] when should be [x]), running twice in dev (Strict Mode, intentional), effect setting state that triggers the same effect (infinite loop), depending on object/function literal that's new every render (always re-fires), forgetting cleanup (subscriptions accumulate), async work without abort (race conditions). Use the react-hooks/exhaustive-deps lint rule + React DevTools Profiler.
useEffect bugs cluster around a few patterns. Debugging means matching the symptom to one of these.
Symptom 1: effect runs twice in dev
useEffect(() => {
console.log('mounted');
}, []);
// dev console: "mounted" "mounted"Cause: React 18 Strict Mode intentionally mounts → unmounts → re-mounts the component in dev to surface effects that aren't idempotent.
Fix: ensure the effect's setup + cleanup is symmetric:
- Subscribe in setup → unsubscribe in cleanup.
- Open WebSocket → close in cleanup.
- Set timer → clear in cleanup.
If you can't make it idempotent (e.g., POST a "session started" event), move that effect to a useState initializer or to a once-per-process module-level guard.
Symptom 2: stale value inside effect
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always 1, never 2/3/4...
}, 1000);
return () => clearInterval(id);
}, []);Cause: count is captured at the time the effect ran (count = 0). The effect doesn't re-run because deps are [].
Fix: functional setState:
setCount(prev => prev + 1);Symptom 3: effect fires every render
useEffect(() => {
fetch(url, { headers: { Auth: token } });
}, [{ Auth: token }]); // new object every renderCause: dep array contains an object/array literal that's a new reference every render. Effect always sees a "changed" dep.
Fix: depend on primitives, or memoize the object:
useEffect(() => {
fetch(url, { headers: { Auth: token } });
}, [url, token]);Symptom 4: missing dependency, stale data
useEffect(() => {
fetch(`/users/${id}`).then(setUser);
}, []); // never re-fetches when id changesCause: id should be in the dep array.
Fix:
useEffect(() => {
fetch(`/users/${id}`).then(setUser);
}, [id]);Enable eslint-plugin-react-hooks with exhaustive-deps rule to catch this automatically.
Symptom 5: infinite loop
const [data, setData] = useState(null);
useEffect(() => {
fetch(url).then(r => r.json()).then(setData);
}, [data]); // depends on data, sets data → loopCause: effect mutates the value it depends on.
Fix: remove the cyclic dep. If you genuinely need to react to data changes with a fetch, restructure (probably you don't want to fetch based on a fetch result this way).
Symptom 6: setState on unmounted component
useEffect(() => {
fetch(url).then(setData);
// no cleanup
}, [url]);If the component unmounts (or url changes) before fetch resolves, setData runs on a no-longer-mounted component.
Fix: AbortController:
useEffect(() => {
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(setData)
.catch(err => { if (err.name !== 'AbortError') setError(err); });
return () => ctrl.abort();
}, [url]);Symptom 7: subscriptions accumulate
useEffect(() => {
window.addEventListener('resize', onResize);
}, []);Effect re-runs (if deps change) without cleanup → listener piles up.
Fix: cleanup:
useEffect(() => {
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);Symptom 8: effect runs in wrong order vs paint
User sees the un-updated UI for one frame, then the effect runs and updates → flash.
Cause: useEffect runs after paint. For DOM measurements that need to influence layout before the user sees it, use useLayoutEffect.
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setSize(rect.width);
}, []);(But useLayoutEffect blocks paint — don't use for slow work.)
Symptom 9: effect doesn't run because of conditional
if (someCondition) {
useEffect(() => { ... }, []); // ❌ ESLint will warn
}Cause: hooks must be called unconditionally in the same order on every render.
Fix: conditional inside the effect, not around it:
useEffect(() => {
if (someCondition) { ... }
}, [someCondition]);Symptom 10: depending on a function defined inline
const fetcher = () => fetch(url);
useEffect(() => {
fetcher().then(setData);
}, [fetcher]); // fetcher is new every render → effect always firesFix: wrap in useCallback, or move the function inside the effect.
Debugging process
- Read the symptom and match it against the list above.
- Check the dep array with
exhaustive-depslint rule. - Add console.log at the start of the effect with relevant deps printed.
- Profiler — see if the component re-renders unexpectedly often.
- React DevTools Components — inspect the component's state/props.
- Strict Mode — if double-running is the surprise, expect it in dev.
Mental model
useEffect runs after render commits, when its deps change. Most bugs come from: stale closures (capture values at run time), wrong deps (missing or unstable), missing cleanup (resource leak), or treating Strict Mode dev double-invocation as a bug. The exhaustive-deps lint rule catches a lot.
For data fetching specifically, prefer React Query / SWR over hand-rolled useEffect — they handle abort, dedup, cache, retry, focus refetch correctly out of the box.
Follow-up questions
- •Why does Strict Mode double-invoke effects?
- •When should you use useLayoutEffect over useEffect?
- •How does the exhaustive-deps rule decide what to flag?
- •Why prefer React Query over useEffect for fetching?
Common mistakes
- •Missing dep — stale value or no re-fire.
- •Inline object/function in deps — fires every render.
- •No cleanup — leaks subscriptions/timers/sockets.
- •Stale closure — value captured at effect creation.
- •Working around Strict Mode instead of fixing the impurity.
- •Effect that depends on what it sets — infinite loop.
Performance considerations
- •Effects that fire unnecessarily (unstable deps) cause cascading work — re-fetches, re-subscribes, re-measurements. Profile before assuming an effect is the bottleneck; often the trigger is upstream (parent re-render).
Edge cases
- •Async function passed directly to useEffect is wrong — wrap in inner async fn.
- •Refs don't trigger effect re-runs (intentional).
- •useInsertionEffect for CSS-in-JS libraries.
- •Multiple effects with conflicting cleanup ordering.
- •Effects that depend on derived values from props need careful memoization.
Real-world examples
- •React's own docs walk through these patterns in 'You Might Not Need an Effect'.
- •React Query's docs explicitly argue against fetch-in-useEffect for production code.
- •React DevTools Profiler is the canonical debugging tool.