// LOADING
// LOADING
// LOADING_ARTICLE
No React hook causes more production bugs than useEffect. The API looks simple — run some code after render, optionally depending on some values — but the mental model required to use it correctly is genuinely subtle. This post covers the pitfalls that bite engineers most often and how to sidestep them.
The most common misconception: developers treat the dependency array as a performance knob — "I don't want this to run often, so I'll leave some values out." That breaks the contract. The rule is strict: every reactive value used inside the effect must be listed.
Eslint-plugin-react-hooks enforces this with the exhaustive-deps rule. If you're silencing that rule with a comment, that's almost always a bug waiting to happen.
tsx// Bug: userId is used but not listed — stale closure on userId useEffect(() => { fetchUser(userId).then(setUser); }, []); // eslint-disable-line react-hooks/exhaustive-deps — DON'T do this // Correct useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
A closure captures its surrounding scope at the time it's created. If a useEffect closes over a state variable, and the effect doesn't re-run when that variable changes, the effect will read the old value forever.
tsxfunction Timer() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { // Stale: count is always 0 because this closure was // created once and never re-created console.log(count); setCount(count + 1); // always sets to 1 }, 1000); return () => clearInterval(id); }, []); // missing count }
The correct fix here is the functional updater form of setCount, which doesn't need to close over the current value:
tsxuseEffect(() => { const id = setInterval(() => { setCount(prev => prev + 1); // no stale closure issue }, 1000); return () => clearInterval(id); }, []);
For cases where you genuinely need to read the latest value without re-running the effect, useRef is the right escape hatch.
Infinite loops happen when an effect updates a value that is also listed as a dependency.
tsx// Infinite loop: effect sets data, data is a dep, effect re-runs... const [data, setData] = useState([]); useEffect(() => { setData(prevData => [...prevData, 'new']); }, [data]); // triggers again after every set
The fix is usually to restructure state, remove the circular dependency, or use useReducer so the dispatch function (which is stable) is the dependency instead of the derived state value.
Effects that set up subscriptions, intervals, or async operations need to clean up after themselves. Without cleanup, you get memory leaks and "can't update state on unmounted component" errors (or silent state corruption in React 18+).
tsxuseEffect(() => { let cancelled = false; async function load() { const result = await fetchData(id); if (!cancelled) setData(result); // guard against stale update } load(); return () => { cancelled = true; // effect cleanup }; }, [id]);
For event listeners:
tsxuseEffect(() => { function handleResize() { /* ... */ } window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);
Missing the cleanup function is one of the first things to check when you see duplicate side effects in React 18 Strict Mode — Strict Mode intentionally double-invokes effects in development to surface exactly this class of bug.
React's own documentation now says: if you can compute something from existing state or props during render, don't put it in useEffect. Common anti-patterns:
useMemo or compute inline during render.key prop instead to remount the component.tsx// Anti-pattern: deriving state in an effect const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [fullName, setFullName] = useState(''); useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]); // Better: derive directly const fullName = `${firstName} ${lastName}`;
Objects and functions created inline during render are recreated on every render. Listing them as dependencies causes the effect to re-run every render.
tsx// Bug: options is a new object every render const options = { threshold: 0.5 }; useEffect(() => { const observer = new IntersectionObserver(cb, options); // ... }, [options]); // runs every render // Fix: move stable values outside the component or use useMemo const options = useMemo(() => ({ threshold: 0.5 }), []);
For functions, useCallback stabilizes the reference. The deeper principle: useEffect dependencies need referential stability. This is why understanding React rendering and the Virtual DOM is a prerequisite — renders create new references, and effects depend on referential equality.
AbortController.useMemo/useCallback.Applying these consistently eliminates the majority of useEffect bugs before they reach production. For broader patterns around side effects and architecture in React applications, see React Best Practices.