React useEffect Common Mistakes and How to Fix Them (2026)

useEffect is the most misused hook in React's entire API — and in 2026, with concurrent rendering and Server Components in the mix, the blast radius of getting it wrong is larger than ever. These aren't hypothetical edge cases; they're production bugs that silently corrupt data, leak memory, and tank performance.

The Infinite Loop Trap — Dependency Array Mistakes That Break Performance

Close-up of HTML and JavaScript code on a computer screen in Visual Studio Code.
Photo by Antonio Batinić on Pexels

Understanding How Missing Dependencies Cause Infinite Loops

The mechanism is brutally simple: effect runs → updates state → triggers re-render → effect runs again. React re-executes effects after every render where a listed dependency changed. Miss a dependency, and you short-circuit that protection.

Here's the classic beginner trap:

// ❌ Wrong: This creates an infinite loop
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // reads `count`, but `count` isn't in deps
  }, []); // runs once... but triggers a re-render each time

  return <div>{count}</div>;
}

React runs the effect, setCount fires, component re-renders, and if you add count to the deps array, the effect fires again. You're stuck either way because the logic is fundamentally wrong — you're updating state that the effect itself reads.

// ✅ Correct: Use functional setState to avoid the dependency entirely
useEffect(() => {
  setCount(prev => prev + 1);
}, []); // No dependency on `count` needed

// Or better: move the logic out of useEffect altogether
// if it's triggered by user interaction, use an event handler

The functional updater form prev => prev + 1 doesn't close over count at all — it receives the current value from React directly. Use it whenever you're deriving next state from current state inside an effect.

Debugging Infinite Loops with React DevTools Profiler

Don't just stare at the code. Open React DevTools, go to the Profiler tab, click Record, and watch the flame chart. If a component re-renders dozens of times in a second, you have a loop. Each render bar should be short and few — not a wall of identical frames.

The fastest low-tech method: drop a console.log('effect ran') inside the effect. If your browser console floods instantly, you've confirmed the loop. Add a counter ref to count invocations without triggering more renders:

const runCount = useRef(0);

useEffect(() => {
  runCount.current += 1;
  console.log('Effect ran', runCount.current, 'times');
  // ...
}, [dependency]);

In production, tools like Sentry's performance tracing will show an unusually dense waterfall of identical XHR calls or state updates — a reliable signal that an effect loop slipped through code review.

Object and Function Recreation in Dependency Arrays

React uses shallow (reference) equality to compare dependencies between renders. An object literal like { id: 1 } creates a brand-new reference every render even though its contents haven't changed. This is one of the sneakiest infinite loop sources.

// ❌ Wrong: New object reference created every render
function UserProfile({ userId }) {
  const options = { headers: { Authorization: `Bearer ${token}` } }; // new ref each render

  useEffect(() => {
    fetchUser(options); // effect re-runs every single render
  }, [options]); // options reference always "changed"
}
// ✅ Correct: Memoize or extract primitive values
function UserProfile({ userId, token }) {
  const options = useMemo(
    () => ({ headers: { Authorization: `Bearer ${token}` } }),
    [token] // only recreates when token actually changes
  );

  useEffect(() => {
    fetchUser(options);
  }, [options]);

  // Even better: use primitives directly when possible
  useEffect(() => {
    fetchUser({ headers: { Authorization: `Bearer ${token}` } });
  }, [token]); // primitive string — stable comparison
}

The same problem hits functions. An inline callback defined in render is a new function reference every time. Wrap it in useCallback or define it inside the effect itself where React's exhaustive-deps rule won't complain.

Stale Closures and Async Race Conditions — The Hidden Memory Leak

The Stale Closure Problem in Async Operations

When an async operation starts inside an effect, it captures the current values of state and props at that moment. If those values change before the async work completes, the callback still sees the old snapshot. React doesn't magically update closed-over variables.

// ❌ Wrong: stale `userId` captured in async callback
function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetchPosts(userId).then(data => {
      setPosts(data); // userId here might be stale if prop changed mid-fetch
    });
  }, []); // Missing userId in deps — classic mistake
}
// ✅ Correct: proper dependency + AbortController for cleanup
function UserPosts({ userId }) {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const controller = new AbortController();

    fetchPosts(userId, { signal: controller.signal })
      .then(data => {
        if (!controller.signal.aborted) {
          setPosts(data);
        }
      })
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort(); // cancels if userId changes or component unmounts
  }, [userId]); // userId in deps — re-runs when prop changes
}

Race Conditions in Parallel API Calls

User types "re" → request A fires. User types "rea" → request B fires. Request B resolves first, then request A resolves and overwrites the correct data. Your UI now shows results for "re" even though the search bar says "rea".

The AbortController pattern above handles this. When the dependency changes (each keystroke), cleanup runs and aborts the previous in-flight request before the new one starts. Here's a production-grade search implementation:

// ✅ Search with race condition prevention
function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    const { signal } = controller;

    async function search() {
      try {
        const response = await fetch(`/api/search?q=${query}`, { signal });
        const data = await response.json();
        setResults(data);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError('Search failed');
        }
        // AbortError means a newer request replaced this one — that's fine
      }
    }

    search();
    return () => controller.abort();
  }, [query]);

  return (/* render results */);
}

The AbortError check is non-negotiable. Without it, every aborted request logs a false error to your monitoring dashboard.

Custom Hooks and Effect Abstraction Pitfalls

Custom hooks are great for reuse, but they hide effects from the component that uses them. A stale closure inside useFetch becomes nearly invisible to the developer consuming it.

// ❌ Wrong: useFetch with missing dependency
function useFetch(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch(url).then(r => r.json()).then(setData);
  }, []); // url never updates the effect — stale forever

  return data;
}

// ✅ Correct: dependency-transparent custom hook
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);

    fetch(url, { signal: controller.signal })
      .then(r => r.json())
      .then(data => { setData(data); setLoading(false); })
      .catch(err => { if (err.name !== 'AbortError') setLoading(false); });

    return () => controller.abort();
  }, [url]); // passes url through — caller controls re-fetch behavior

  return { data, loading };
}

See more about structuring reusable hooks in our post on React Custom Hook Patterns.

Cleanup Functions and Memory Leak Prevention — Beyond Unsubscribe

When Cleanup Functions Don't Execute and Silent Memory Leaks Result

Cleanup runs in two situations: before the next effect fires (when deps change) and when the component unmounts. That's it. If you attach a window event listener and forget the cleanup, every remount adds another listener — they stack up.

// ❌ Wrong: listener leaks on every render where `handler` changes
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // No return — listener never removed
}, [handleResize]);

// ✅ Correct: always return cleanup for external subscriptions
useEffect(() => {
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, [handleResize]);

Timer leaks are equally common:

// ❌ Wrong: interval runs forever after unmount
useEffect(() => {
  const id = setInterval(() => setTick(t => t + 1), 1000);
  // missing cleanup
}, []);

// ✅ Correct
useEffect(() => {
  const id = setInterval(() => setTick(t => t + 1), 1000);
  return () => clearInterval(id);
}, []);

To detect leaks, open Chrome DevTools → Memory tab → take a Heap Snapshot before and after mounting/unmounting a component repeatedly. If retained objects grow, you have a leak.

Closure Traps in Cleanup Functions

Cleanup functions close over the same snapshot as the effect body. If your cleanup needs a current value that may have changed, a ref is more reliable than depending on closure state.

// ❌ Wrong: cleanup closes over stale userId
useEffect(() => {
  const sub = subscribeToUser(userId, handleUpdate);

  return () => {
    unsubscribeFromUser(userId, handleUpdate); // may be old userId
  };
}, [userId]);

// ✅ Correct: use the value captured at effect time — that IS the user you subscribed for
// The above is actually fine IF you always clean up the same userId you subscribed to.
// The real problem is when you try to access *current* state in cleanup:

function useWebSocket(userId) {
  const userIdRef = useRef(userId);
  useEffect(() => { userIdRef.current = userId; }, [userId]);

  useEffect(() => {
    const ws = new WebSocket(`/ws/${userId}`);

    return () => {
      // Use the ref only if you need the CURRENT userId at cleanup time
      // For unsubscribing the same connection you opened, capture it in closure:
      ws.close();
    };
  }, [userId]);
}

Edge Cases: Overlapping Effects and Cleanup Order

Under React's Concurrent Mode and Strict Mode (development), effects intentionally fire twice — React mounts, unmounts, and remounts to verify cleanup works correctly. If your app breaks in Strict Mode, your cleanup is incomplete.

// This pattern breaks in Strict Mode — reveals a real bug
useEffect(() => {
  someGlobalSingleton.init(); // called twice in dev
  return () => someGlobalSingleton.destroy(); // destroyed after first mount
}, []);

// ✅ Make initialization idempotent
useEffect(() => {
  if (!someGlobalSingleton.initialized) {
    someGlobalSingleton.init();
  }
  return () => someGlobalSingleton.destroy();
}, []);

Strict Mode's double-invocation behavior is documented in the official React docs on useEffect — read that section before assuming Strict Mode is wrong.

Performance Optimization and 2026 Best Practices

Close-up of JavaScript code on a laptop screen, showcasing programming in progress.
Photo by Markus Winkler on Pexels

Splitting Effects by Concern

One effect, one responsibility. Developers who merge unrelated side effects into a single useEffect create dependencies that fight each other and make debugging miserable.

// ❌ Wrong: two concerns in one effect
useEffect(() => {
  document.title = `${user.name} — Dashboard`;
  analytics.trackPageView(route);
}, [user.name, route]); // one change fires both side effects

// ✅ Correct: split into independent effects
useEffect(() => {
  document.title = `${user.name} — Dashboard`;
}, [user.name]);

useEffect(() => {
  analytics.trackPageView(route);
}, [route]);

useEffect vs useLayoutEffect — Picking the Right Tool

Most effects belong in useEffect. Use useLayoutEffect only when you need to read or mutate the DOM synchronously before the browser paints — things like measuring element dimensions or preventing layout flicker on initial render.

Scenario Correct Hook Why
Fetch data from API useEffect Async, no DOM measurement needed
Subscribe to events useEffect Doesn't block paint
Read DOM element size useLayoutEffect Must run before paint to avoid flicker
Animate element on mount useLayoutEffect Synchronous DOM setup before browser sees it
Update document title useEffect No visual impact from timing
Set up WebSocket useEffect Async setup, doesn't block render

useLayoutEffect runs synchronously and blocks painting — misusing it on slow operations will visibly freeze your UI. Default to useEffect and only reach for useLayoutEffect when you have a concrete visual artifact to fix.

The eslint-plugin-react-hooks Enforcer — Use It, Always

The eslint-plugin-react-hooks exhaustive-deps rule catches missing dependencies automatically. In 2026 there's no excuse for not running it. Configure it as an error, not a warning:

// .eslintrc.json
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "error"
  }
}

When the linter tells you to add a dependency and that would cause a re-render problem, the fix is almost never to suppress the warning. It's to restructure the code — use a ref, move logic outside the component, or split the effect. See our broader breakdown in React Hooks Performance Patterns.

Frequently Asked Questions

Q: Why does my useEffect run twice in development but not production?

A: React Strict Mode intentionally mounts, unmounts, and remounts components in development to surface cleanup bugs. This behavior doesn't happen in production builds. If double-firing breaks your app, you have a missing or incorrect cleanup function — fix the cleanup, don't disable Strict Mode.

Q: Should I use useEffect for derived state?

A: No. Computing a value from existing state or props doesn't need an effect at all — calculate it directly during render or memoize it with useMemo. Using useEffect to sync state with other state adds an extra render cycle and makes data flow harder to trace.

Q: When should I use an empty dependency array vs. no array at all?

A: An empty array [] runs the effect once after initial mount — use it for one-time setup like connecting to a service. No array runs the effect after every render — this is rarely intentional and almost always a bug or a sign the effect belongs in an event handler instead.

Wrap-up

The four failure modes — dependency array mistakes, stale closures, missing cleanup, and split-concern violations — cover the overwhelming majority of useEffect bugs you'll encounter in real codebases. The mental shift is from "lifecycle method" to "synchronization with an external system": if you're not syncing with something outside React, you probably don't need an effect at all.

Start today by enabling eslint-plugin-react-hooks as an error in your project — it will surface every issue covered in this post before it ships.

Comments

Popular posts from this blog

Node.js Error Handling Best Practices 2026: Complete Guide

How to Use Docker for Local Development (Complete Guide 2026)

Python Async Await Explained With Real Examples