Skip to content

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.

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:

// Props
useEffect(() => { doSomething(userId); }, [userId]);
// State
useEffect(() => { save(categories); }, [categories]);
// Variables derived from props or state
const key = `zb-${year}-${month}`;
useEffect(() => { loadMonth(key); }, [key]);

What does NOT belong:

// Stable references that never change
useEffect(() => { 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 it

The most common useEffect mistake: updating state inside an effect that has that state in its dependencies:

// Bug: infinite loop
useEffect(() => {
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 count

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 render
const options = { year: currentYear, month: currentMonth };
useEffect(() => { loadData(options); }, [options]); // runs every render
// Fix: depend on the primitives directly
useEffect(() => { 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.

The cleanup function runs:

  1. Before the effect runs again (due to a dependency change)
  2. When the component unmounts

This means you get: mount → effect → [change] → cleanup → effect → [unmount] → cleanup.

Common things to clean up:

// Timer
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// Event listener
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Fetch abort
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(setData);
return () => controller.abort();
}, [url]);
  1. Write a useEffect that 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).
  2. Write a useEffect that reads from localStorage using a key derived from currentYear and currentMonth. Depend on currentYear and currentMonth, not on a combined object.
  3. Find a useEffect in 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.
  • setState functions 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.