Skip to main content

πŸ”Œ The useEffect Hook

Rendering is about turning data into UI β€” a pure calculation with no outside contact. But real components also need to reach out: fetch data from an API, start a timer, listen for a keypress, sync with the browser. These are side effects, and useEffect is the Hook that runs them safely, after the screen is painted, and cleans them up when you're done.

Week 4 · Day 2 (Tuesday: State and Lifecycle) · Lecture 2

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Define a side effect and recognize why it belongs in useEffect
  • Write an effect and control its timing with the dependency array
  • Distinguish the three dependency patterns: none, [], and [deps]
  • Return a cleanup function to tear down timers, listeners, and subscriptions
  • Fetch data on mount and cancel stale requests
  • Avoid the two classic bugs: infinite loops and stale closures

Estimated Time: 70 minutes

Practice: Build a live clock, a window-size tracker, and a debounced search β€” each with proper cleanup.

In This Lesson

What Is a Side Effect?

A component's render is meant to be pure: given the same props and state, it returns the same JSX and touches nothing outside itself. Anything that breaks that purity β€” talking to a server, reading window, starting a timer, subscribing to a socket β€” is a side effect.

Picture a chef. Cooking the dish from the ingredients on the counter is the render. Ordering more ingredients, setting a kitchen timer, or turning on the extractor fan are side effects β€” interactions with the world beyond the recipe. You don't do them mid-chop; you do them at the right moment. useEffect is React's "right moment": it runs your effect after the render is committed to the screen.

graph TD A[Component renders] --> B[React commits UI to the DOM] B --> C[useEffect runs your side effect] C --> D[Data fetching] C --> E[Event listeners] C --> F[Timers / intervals] C --> G[Subscriptions] C --> H[Browser APIs]

πŸ’‘ Why after render, not during?

Running a fetch or a timer during render would block the UI and could fire repeatedly as React re-renders. By deferring effects until after the DOM is painted, React keeps rendering fast and predictable, and your effect sees the real, on-screen UI.

useEffect Syntax

useEffect takes two arguments: a setup function (your effect) and an optional dependency array. The setup runs after render; if it returns a function, that returned function is the cleanup.

import { useEffect } from 'react';

useEffect(() => {
    // 1. Setup β€” runs after the render is committed
    //    Do your side effect here.

    return () => {
        // 2. Cleanup (optional) β€” runs before the next effect
        //    and when the component unmounts.
    };
}, [/* 3. dependencies */]);

A first, minimal effect that runs after every render:

import { useState, useEffect } from 'react';

function Logger() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        console.log('Rendered β€” count is now', count);
    }); // no dependency array β†’ runs after EVERY render

    return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

The Dependency Array

The second argument controls when the effect re-runs. This is the single most important thing to understand about useEffect. There are three patterns.

What you writeWhen the effect runsTypical use
(omitted)After every renderRare β€” usually a mistake
[]Once, after the first render (mount)Fetch initial data, set up a subscription
[a, b]On mount, then whenever a or b changesRe-fetch when an id or query changes

1. Run once, on mount

function Welcome() {
    useEffect(() => {
        console.log('Mounted β€” runs exactly once');
    }, []); // empty array β†’ mount only

    return <h1>Welcome!</h1>;
}

2. Run when a value changes

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);

    useEffect(() => {
        fetchUser(userId).then(setUser);
    }, [userId]); // re-runs whenever userId changes

    if (!user) return <p>Loading…</p>;
    return <p>{user.name}</p>;
}

⚠️ Include every value the effect uses

Your dependency array must list every prop, state, or variable the effect reads. Leaving one out gives you a stale closure β€” the effect keeps using an old value. The eslint-plugin-react-hooks "exhaustive-deps" rule flags these for you; trust it.

Cleanup Functions

Anything you start in an effect, you must stop: intervals, event listeners, subscriptions, open connections. Return a function from your effect and React runs it as cleanup β€” before the effect runs again, and once more when the component unmounts. Think of it as tidying your workspace before the next task and before you leave.

function Timer() {
    const [seconds, setSeconds] = useState(0);

    useEffect(() => {
        // Setup: start the interval
        const id = setInterval(() => {
            setSeconds(s => s + 1); // functional update β€” always fresh
        }, 1000);

        // Cleanup: stop it so it doesn't leak or double up
        return () => clearInterval(id);
    }, []); // set up once on mount

    return <p>Seconds: {seconds}</p>;
}
function WindowSize() {
    const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });

    useEffect(() => {
        const handleResize = () =>
            setSize({ w: window.innerWidth, h: window.innerHeight });

        window.addEventListener('resize', handleResize);
        // Cleanup: remove the listener to avoid a memory leak
        return () => window.removeEventListener('resize', handleResize);
    }, []);

    return <p>{size.w} Γ— {size.h}</p>;
}

⚠️ Forgetting cleanup = memory leaks & duplicate work

Without clearInterval, every re-mount stacks another ticking interval. Without removeEventListener, listeners pile up on window and keep firing after the component is gone β€” often throwing "can't set state on an unmounted component" warnings.

The Effect Lifecycle

Trace one effect through a component's life: it sets up after the first render, and if it has dependencies, it cleans up and re-runs each time those change, then cleans up one final time on unmount.

sequenceDiagram participant C as Component participant E as Effect setup participant R as Cleanup C->>C: First render committed C->>E: Run setup Note over E: subscribe / start timer / fetch C->>C: Re-render (a dependency changed) E->>R: Run cleanup for the old effect C->>E: Run setup again with new values C->>C: Component unmounts E->>R: Run final cleanup

πŸ’‘ One effect, one job

Each useEffect should handle a single concern. Need to fetch data and listen for resizes? Use two effects. They're independent, easier to read, and each gets its own focused dependency array and cleanup.

Data Fetching

The most common effect: load data when the component mounts (or when an id changes). Handle three states β€” loading, error, success β€” and guard against a subtle bug where a slow response arrives after the component has moved on.

function UserList() {
    const [users, setUsers]     = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError]     = useState(null);

    useEffect(() => {
        let ignore = false; // guard against stale responses

        async function load() {
            try {
                setLoading(true);
                const res = await fetch('https://api.example.com/users');
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const data = await res.json();
                if (!ignore) setUsers(data); // only apply if still relevant
            } catch (err) {
                if (!ignore) setError(err.message);
            } finally {
                if (!ignore) setLoading(false);
            }
        }

        load();
        // Cleanup: mark this run stale if the component re-runs/unmounts
        return () => { ignore = true; };
    }, []); // fetch once on mount

    if (loading) return <p>Loading…</p>;
    if (error)   return <p>Error: {error}</p>;

    return (
        <ul>
            {users.map(u => <li key={u.id}>{u.name}</li>)}
        </ul>
    );
}

βœ… The ignore flag prevents race conditions

If userId changes quickly, request A might resolve after request B, overwriting fresh data with stale data. The cleanup sets ignore = true for the outgoing effect, so its late response is discarded. A modern alternative is AbortController to actually cancel the fetch.

πŸ’‘ In real projects, reach for a data library

Manual fetch-in-effect is great for learning, but production apps usually use TanStack Query or a framework's data loader, which handle caching, retries, and race conditions for you. Understanding the effect underneath makes those tools far less magical.

More Common Effects

Subscriptions

function ChatRoom({ roomId }) {
    const [messages, setMessages] = useState([]);

    useEffect(() => {
        const sub = subscribeToChat(roomId, (msg) =>
            setMessages(prev => [...prev, msg])
        );
        return () => sub.unsubscribe(); // resubscribe cleanly when roomId changes
    }, [roomId]);

    return messages.map((m, i) => <p key={i}>{m}</p>);
}

Debounced search

Wait until the user pauses typing before hitting the API. The cleanup cancels the pending timeout on every keystroke, so only the last one fires.

function SearchBox() {
    const [query, setQuery]     = useState('');
    const [results, setResults] = useState([]);

    useEffect(() => {
        if (!query) { setResults([]); return; }

        const id = setTimeout(() => {
            searchAPI(query).then(setResults);
        }, 300); // wait 300ms after the last keystroke

        return () => clearTimeout(id); // cancel if query changes first
    }, [query]);

    return (
        <div>
            <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search…" />
            <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
        </div>
    );
}

Syncing to localStorage

function Notes() {
    const [text, setText] = useState(() => localStorage.getItem('notes') || '');

    useEffect(() => {
        localStorage.setItem('notes', text); // persist on every change to text
    }, [text]);

    return <textarea value={text} onChange={(e) => setText(e.target.value)} />;
}

Dependency Pitfalls

1. Infinite loops

If an effect sets state that's in its own dependency array, it re-runs forever. Break the cycle with the functional update form or a correct dependency list.

// ❌ Infinite loop: effect sets count, count is a dependency, repeat forever
useEffect(() => {
    setCount(count + 1);
}, [count]);

// βœ… If you truly need to increment once, do it without depending on count,
//    or rethink whether this belongs in an effect at all.

2. Stale closures

// ❌ count is captured once and never updates β†’ always 0 + 1
useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
}, []); // count missing from deps

// βœ… functional update reads the latest value, no dependency needed
useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id);
}, []);

3. Object & function dependencies change every render

Objects and functions created inside the component are new on every render, so an effect that depends on them re-runs every time. Depend on primitive values, move the object out, or memoize with useMemo/useCallback.

// ❌ filters is a brand-new object each render β†’ effect runs every render
function UserSearch() {
    const filters = { active: true, role: 'admin' };
    useEffect(() => { fetchUsers(filters); }, [filters]);
}

// βœ… depend on the primitive fields instead
function UserSearch() {
    const active = true;
    const role   = 'admin';
    useEffect(() => { fetchUsers({ active, role }); }, [active, role]);
}

Practice & Quiz

πŸ‹οΈ Exercise 1: A ticking clock

Goal: Build a Clock that displays the current time and updates every second. Clean up the interval on unmount.

function Clock() {
    // TODO: store the current time in state
    // TODO: start an interval that updates it every second
    // TODO: clear the interval in cleanup
}
πŸ’‘ Hint

Initialize state with new Date(). In an effect with [] deps, setInterval to call setTime(new Date()) each second, and return () => clearInterval(id).

βœ… Solution
function Clock() {
    const [time, setTime] = useState(new Date());

    useEffect(() => {
        const id = setInterval(() => setTime(new Date()), 1000);
        return () => clearInterval(id);
    }, []);

    return <p>{time.toLocaleTimeString()}</p>;
}

πŸ‹οΈ Exercise 2: Document title sync

Goal: A counter whose value is also shown in the browser tab title (document.title), staying in sync as the count changes.

βœ… Solution
function TitleCounter() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        document.title = `Clicked ${count} times`;
    }, [count]); // re-run whenever count changes

    return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

🎯 Quick Quiz

Question 1: When does an effect with an empty dependency array [] run?

Question 2: What is the returned function inside an effect for?

Question 3: An interval effect logs the wrong count every time. The most likely fix is:

Best Practices & Pitfalls

βœ… Do

  • Give every effect a correct, complete dependency array
  • Return cleanup for anything you subscribe to, listen on, or schedule
  • Keep one effect focused on one concern β€” split unrelated logic
  • Use functional updates inside timers/listeners to dodge stale closures
  • Guard async responses with an ignore flag or AbortController

❌ Don't

  • Set state a dependency reads without a plan β€” that's an infinite loop
  • Omit dependencies to "make it run once" if the effect uses changing values
  • Put derived values in an effect when you could compute them during render
  • Forget cleanup β€” leaked intervals and listeners are a top React bug

⚠️ Not everything needs an effect

If a value can be calculated from props and state during render, do that instead of storing it in state and syncing it with an effect. Effects are for reaching outside React β€” DOM, network, timers β€” not for transforming data you already have.

Summary

πŸŽ‰ Key Takeaways

  • Side effects reach outside the component β€” fetching, timers, listeners, DOM β€” and run in useEffect
  • Effects run after the render is committed to the screen
  • The dependency array controls re-runs: omitted = every render, [] = mount only, [deps] = when deps change
  • Return a cleanup function to tear down anything you set up
  • Watch for infinite loops and stale closures; functional updates fix most closure bugs
  • Include every value the effect uses in its dependency array β€” let the linter help

πŸ“š Additional Resources

πŸš€ What's Next?

You've met state and effects individually. Next we zoom out to see how they combine over a component's whole life β€” mounting, updating, and unmounting β€” in Component Lifecycle, where useEffect reveals itself as the modern replacement for the old class lifecycle methods.

πŸŽ‰ Your components can talk to the world now!

Fetching, timing, listening, syncing β€” with cleanup done right, you can wire React to anything.