useEffect Fundamentals — Side Effects and the Dependency Array

React components are pure functions of their props and state — given the same input, they produce the same output. But real applications need to do things beyond rendering: fetch data, set up timers, subscribe to WebSocket events, update the document title. These are side effects — operations that reach outside the component and affect the world. The useEffect hook is React’s designated place for side effects: it runs after the render is committed to the DOM, keeping the render function pure while still allowing necessary impure work.

The Three Forms of useEffect

import { useEffect, useState } from "react";

// ── Form 1: No dependency array — runs after EVERY render ─────────────────────
// Rarely correct — usually an unintentional infinite loop waiting to happen
useEffect(() => {
    console.log("Component rendered");
    // Runs after every render, including re-renders triggered by this effect!
});

// ── Form 2: Empty array [] — runs ONCE on mount, cleanup on unmount ────────────
// Correct for: one-time setup, initial data fetch, event listeners
useEffect(() => {
    fetchInitialData();
    document.title = "Blog - Home";

    return () => {
        // Cleanup runs when component unmounts
        document.title = "Blog";
    };
}, []);  // ← empty array = only on mount/unmount

// ── Form 3: Array with values — runs when those values change ─────────────────
// Correct for: re-fetching when a param changes, reacting to prop changes
function PostFeed({ page, tag }) {
    const [posts, setPosts] = useState([]);

    useEffect(() => {
        fetchPosts({ page, tag }).then(setPosts);
        // Re-runs whenever page or tag changes
    }, [page, tag]);  // ← dependency array

    return <PostList posts={posts} />;
}
Note: The dependency array tells React “re-run this effect when any of these values change.” React uses shallow equality to compare dependencies — for primitive values (strings, numbers, booleans), this works as expected. For objects and arrays, the comparison is reference equality: a new array [1, 2, 3] created on every render is never equal to the previous [1, 2, 3] even though the contents are the same. This is why passing an array or object literal directly in the dependency array often causes infinite re-fetch loops.
Tip: ESLint’s react-hooks/exhaustive-deps rule (included with eslint-plugin-react-hooks) warns when you use a value inside useEffect without listing it in the dependency array. Follow these warnings — omitting a dependency is almost always a bug that causes stale data. If the lint rule triggers for a function, either move the function inside the effect or wrap it in useCallback to stabilise its reference.
Warning: Never put an effect inside a loop or condition. The rules of hooks apply to useEffect just as they do to useState — it must be called unconditionally at the top level of the component. If you need different effects based on a condition, put the condition inside the effect function: useEffect(() => { if (isLoggedIn) { fetchUserData(); } }, [isLoggedIn]).

Common useEffect Patterns

// ── Document title ────────────────────────────────────────────────────────────
function PostDetailPage({ post }) {
    useEffect(() => {
        if (post?.title) {
            document.title = `${post.title} | Blog`;
        }
        return () => { document.title = "Blog"; };
    }, [post?.title]);

    return <article>...</article>;
}

// ── Scroll to top on page change ───────────────────────────────────────────────
function App() {
    const { pathname } = useLocation();   // from React Router
    useEffect(() => {
        window.scrollTo(0, 0);
    }, [pathname]);   // scroll up whenever the route changes
}

// ── Window resize listener ────────────────────────────────────────────────────
function useWindowWidth() {
    const [width, setWidth] = useState(window.innerWidth);
    useEffect(() => {
        const handler = () => setWidth(window.innerWidth);
        window.addEventListener("resize", handler);
        return () => window.removeEventListener("resize", handler);   // cleanup!
    }, []);   // add listener once, remove on unmount
    return width;
}

The Execution Order

React rendering and effect lifecycle:

1. Component function runs (render)
   → React calculates what the DOM should look like
2. React updates the DOM (commit)
3. useEffect runs (after paint)
   → Async: does not block the browser from painting
4. Component re-renders (state/prop changes)
   → Previous effect cleanup runs first
   → New effect runs after the new render

Cleanup timing:
  - On component UNMOUNT: cleanup of the last effect runs
  - On dependency CHANGE: cleanup of the previous effect runs
                          BEFORE the new effect runs

Common Mistakes

Mistake 1 — Missing dependency causes stale closure

❌ Wrong — setCount uses stale count value:

const [count, setCount] = useState(0);
useEffect(() => {
    const id = setInterval(() => {
        setCount(count + 1);   // count is always 0 — stale closure!
    }, 1000);
    return () => clearInterval(id);
}, []);   // count is missing from deps!

✅ Correct — use functional update form or add count to deps:

useEffect(() => {
    const id = setInterval(() => {
        setCount((c) => c + 1);   // ✓ functional form — always fresh value
    }, 1000);
    return () => clearInterval(id);
}, []);   // empty array is now correct ✓

Mistake 2 — Object/array in dependency array (infinite loop)

❌ Wrong — new object on every render triggers infinite loop:

function Component({ userId }) {
    useEffect(() => {
        fetch(`/api/users/${userId}`);
    }, [{ id: userId }]);   // new object on every render → infinite loop!

✅ Correct — use the primitive value:

useEffect(() => {
    fetch(`/api/users/${userId}`);
}, [userId]);   // ✓ primitive string/number comparison works correctly

Quick Reference

Dependency Array When Effect Runs Use For
Omitted After every render Rarely — usually a mistake
[] Once (on mount) One-time setup, initial fetch
[val] When val changes Re-fetch when param changes
[a, b] When a or b changes Multiple params

🧠 Test Yourself

A component fetches post details using useEffect(() => { fetchPost(postId); }, []);. The component is used on a page where postId can change (the user navigates between posts). What bug does this create?