Cleanup and Dependencies
useEffect is powerful but has two sharp edges: incorrect dependency arrays cause bugs that are hard to debug, and effects that do not clean up cause memory leaks and unexpected behavior. This lesson makes both explicit.
What belongs in the dependency array
Section titled “What belongs in the dependency array”Everything your effect reads that could change over time belongs in the dependency array. React’s linter (eslint-plugin-react-hooks) will warn you if you miss something.
Common items that belong:
// PropsuseEffect(() => { doSomething(userId); }, [userId]);
// StateuseEffect(() => { save(categories); }, [categories]);
// Variables derived from props or stateconst key = `zb-${year}-${month}`;useEffect(() => { loadMonth(key); }, [key]);What does NOT belong:
// Stable references that never changeuseEffect(() => { document.title = 'ZeroBudget'; }, []);// No dependencies — document.title doesn't change
// setState functions from useState (stable by guarantee)useEffect(() => { setData([]); }, []); // setData never changes — no need to include itInfinite loop pattern
Section titled “Infinite loop pattern”The most common useEffect mistake: updating state inside an effect that has that state in its dependencies:
// Bug: infinite loopuseEffect(() => { setCount(count + 1); // updates count}, [count]); // count change re-runs the effect → updates count → re-runs...The fix is to use the functional update form, which reads the previous value without needing count as a dependency:
useEffect(() => { setCount(prev => prev + 1);}, []); // runs once — no dependency on countObject and array dependencies
Section titled “Object and array dependencies”Objects and arrays are compared by reference in JavaScript. A new object {} is never equal to another new object {} even if they have the same content. If you create an object or array inside the component body and put it in the dependency array, the effect runs on every render:
// Bug: options is a new object every renderconst options = { year: currentYear, month: currentMonth };useEffect(() => { loadData(options); }, [options]); // runs every render
// Fix: depend on the primitives directlyuseEffect(() => { loadData({ year: currentYear, month: currentMonth }); }, [currentYear, currentMonth]);Depend on primitive values (strings, numbers, booleans) when possible. If you must use an object, create it inside the effect or stabilize it with useMemo.
Cleanup timing
Section titled “Cleanup timing”The cleanup function runs:
- Before the effect runs again (due to a dependency change)
- When the component unmounts
This means you get: mount → effect → [change] → cleanup → effect → [unmount] → cleanup.
Common things to clean up:
// TimeruseEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id);}, []);
// Event listeneruseEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);}, []);
// Fetch abortuseEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }).then(setData); return () => controller.abort();}, [url]);Exercise
Section titled “Exercise”- Write a
useEffectthat starts a 5-second interval logging'tick'to the console. Verify it clears when you navigate away or the component unmounts (wrap it in a toggle to unmount/remount it). - Write a
useEffectthat reads fromlocalStorageusing akeyderived fromcurrentYearandcurrentMonth. Depend oncurrentYearandcurrentMonth, not on a combined object. - Find a
useEffectin your code with a missing dependency (use the linter warning as a guide) and fix it.
- Every reactive value the effect reads belongs in the dependency array.
setStatefunctions are stable — you do not need to include them.- Never update state inside an effect that lists that state as a dependency — it causes infinite loops.
- Depend on primitives rather than objects or arrays to avoid effects running on every render.
- Clean up timers, listeners, and in-flight fetches by returning a cleanup function.