HomeGuidesReactReact useEffect Explained — Dependencies Array, Cleanup & Pitfalls
⚛️ React

React useEffect: Dependencies, Cleanup, and Common Pitfalls

useEffect is the most misunderstood React hook. Here's a clear guide to dependencies, cleanup, and the pitfalls that cause bugs.

Examifyr·2026·6 min read

useEffect fundamentals

useEffect runs side effects after every render, or conditionally based on dependencies.

import { useEffect } from 'react';

// Runs after EVERY render (no dependency array)
useEffect(() => {
    document.title = `Count: ${count}`;
});

// Runs ONCE after mount (empty dependency array)
useEffect(() => {
    fetchData();
}, []);

// Runs when count changes
useEffect(() => {
    console.log('count changed to', count);
}, [count]);
Note: The dependency array controls WHEN the effect runs — not a performance optimisation. Put every value from the component scope that the effect uses.

Cleanup functions

Return a function from useEffect to clean up subscriptions, timers, and event listeners.

useEffect(() => {
    const handler = () => console.log('scrolled');
    window.addEventListener('scroll', handler);

    // Cleanup: runs before the effect re-runs AND when component unmounts
    return () => {
        window.removeEventListener('scroll', handler);
    };
}, []);

// Timer cleanup
useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id);
}, []);
Note: Without cleanup, event listeners and timers continue running after the component unmounts — a common source of memory leaks.

Fetching data in useEffect

Data fetching is the most common useEffect use case. Handle loading and error states properly.

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        let cancelled = false;  // prevent stale updates

        async function load() {
            try {
                setLoading(true);
                const res = await fetch(`/api/users/${userId}`);
                const data = await res.json();
                if (!cancelled) setUser(data);
            } catch (e) {
                if (!cancelled) setError(e.message);
            } finally {
                if (!cancelled) setLoading(false);
            }
        }

        load();
        return () => { cancelled = true; };
    }, [userId]);  // re-fetch when userId changes
}

The infinite loop trap

Missing or incorrect dependencies cause useEffect to run in an infinite loop.

// INFINITE LOOP: data is created on every render
// and changes reference each time
useEffect(() => {
    setUser(data);
}, [data]);  // data is a new object every render

// INFINITE LOOP: setting state that's in the dependency array
useEffect(() => {
    setCount(count + 1);  // triggers re-render
}, [count]);              // which triggers effect again

// FIX: use functional update to remove count from deps
useEffect(() => {
    setCount(prev => prev + 1);  // no need for count in deps
}, []);
Note: If your effect causes an infinite loop, you've either set state from state that's in the deps array, or you're including an unstable reference (object/array) in the deps.

Exam tip

The most common useEffect interview question: "Why does this cause an infinite loop?" — usually because the dependency array contains a value that's recreated on every render (like an inline object or function), or because the effect sets state that's also in the deps array.

🎯

Think you're ready? Prove it.

Take the free React readiness test. Get a score, topic breakdown, and your exact weak areas.

Take the free React test →

Free · No sign-up · Instant results

← Previous
React useState Hook — State Management, Updates & Common Patterns
Next →
React Hooks Explained — useRef, useMemo, useCallback & useContext
← All React guides