Back to React
React
easy
mid

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.

9 min read·~15 min to think through

useEffect bugs cluster around a few patterns. Debugging means matching the symptom to one of these.

Symptom 1: effect runs twice in dev

jsx
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

jsx
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:

jsx
setCount(prev => prev + 1);

Symptom 3: effect fires every render

jsx
useEffect(() => {
  fetch(url, { headers: { Auth: token } });
}, [{ Auth: token }]);  // new object every render

Cause: 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:

jsx
useEffect(() => {
  fetch(url, { headers: { Auth: token } });
}, [url, token]);

Symptom 4: missing dependency, stale data

jsx
useEffect(() => {
  fetch(`/users/${id}`).then(setUser);
}, []);   // never re-fetches when id changes

Cause: id should be in the dep array.

Fix:

jsx
useEffect(() => {
  fetch(`/users/${id}`).then(setUser);
}, [id]);

Enable eslint-plugin-react-hooks with exhaustive-deps rule to catch this automatically.

Symptom 5: infinite loop

jsx
const [data, setData] = useState(null);
useEffect(() => {
  fetch(url).then(r => r.json()).then(setData);
}, [data]);   // depends on data, sets data → loop

Cause: 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

jsx
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:

jsx
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

jsx
useEffect(() => {
  window.addEventListener('resize', onResize);
}, []);

Effect re-runs (if deps change) without cleanup → listener piles up.

Fix: cleanup:

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

jsx
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

jsx
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:

jsx
useEffect(() => {
  if (someCondition) { ... }
}, [someCondition]);

Symptom 10: depending on a function defined inline

jsx
const fetcher = () => fetch(url);
useEffect(() => {
  fetcher().then(setData);
}, [fetcher]);   // fetcher is new every render → effect always fires

Fix: wrap in useCallback, or move the function inside the effect.

Debugging process

  1. Read the symptom and match it against the list above.
  2. Check the dep array with exhaustive-deps lint rule.
  3. Add console.log at the start of the effect with relevant deps printed.
  4. Profiler — see if the component re-renders unexpectedly often.
  5. React DevTools Components — inspect the component's state/props.
  6. 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.

Senior engineer discussion

Seniors recognize the symptom-to-cause map quickly, use lint + Profiler to catch issues early, and reach for React Query / SWR for data fetching to avoid the most common useEffect bugs. They also know Strict Mode double-invocation is a feature, not a bug.

Related questions