Skip to main content

🔄 useEffect Hook for Side Effects

Rendering should be pure — the same props and state produce the same UI. But real apps also need to reach outside: fetch data, subscribe to sockets, start timers, or touch the DOM. useEffect is React's designated place for that work, and its dependency array plus cleanup function are the two ideas you must truly understand.

🎯 Learning Objectives

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

  • Explain what a side effect is and why it belongs in useEffect rather than in the render body
  • Control when an effect runs using the dependency array (none, empty, or listed dependencies)
  • Write a cleanup function to tear down subscriptions, timers, and listeners
  • Fetch data safely, avoiding race conditions with an ignore flag or AbortController
  • Diagnose and fix the classic pitfalls: infinite loops, missing dependencies, and stale closures

Estimated Time: 40–50 minutes  •  Difficulty: Intermediate

Hands-on: Build a live search box that fetches results with debouncing and race-condition protection.

In This Lesson

What Is a Side Effect?

A React component's job is to take props and state and return JSX — nothing more. That calculation should be pure: no network calls, no timers, no writing to document.title, no reaching into the DOM. Purity is what lets React re-run your component freely and trust the result.

A side effect is any operation that steps outside that pure calculation to interact with the world beyond the component:

  • Fetching data from an API
  • Subscribing to a WebSocket, event emitter, or browser event
  • Setting timers with setTimeout / setInterval
  • Manually reading or writing the DOM (focus, scroll, measurements)
  • Logging, analytics, or syncing to localStorage
💡 A useful analogy: Your main job while cooking dinner is preparing the meal (rendering). But the doorbell rings, a timer goes off, the phone buzzes — errands that aren't cooking but still have to happen (side effects). useEffect is the note on the fridge that says "after each time you plate the food, run these errands."
Pure rendering versus side effects A component splits into a pure render path that returns JSX and a side-effect path handled by useEffect for fetching, subscriptions, timers, and DOM work. Component runs Pure render compute & return JSX same input → same output Side effects — useEffect fetch · subscribe · timers runs after paint
Figure 1 — Keep the render path pure and push everything that touches the outside world into useEffect, which React runs after the browser has painted.

Because effects run after render, they never block the user from seeing the UI, and React can synchronize them with your component's lifecycle in a predictable way.

Syntax & the Dependency Array

useEffect takes two arguments: a setup function containing the effect, and an optional dependency array that tells React when to re-run it.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // The side effect: sync the browser tab title to state
    document.title = `You clicked ${count} times`;

    // Optional cleanup: restore the title when the effect is torn down
    return () => {
      document.title = 'React App';
    };
  }, [count]); // Re-run only when `count` changes

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

The three flavors of dependency array

The dependency array is the single most important part of useEffect. It answers one question: "which values, if they change, should make this effect run again?"

Dependency array When the effect runs Typical use
useEffect(fn)
(omitted)
After every render Rarely what you want; usually a bug
useEffect(fn, [])
(empty)
Once, after the first render One-time setup: subscriptions, initial fetch
useEffect(fn, [a, b]) After first render, then whenever a or b changes Re-syncing when specific values change

⚠️ Effects synchronize, they don't "run on mount"

It's tempting to think of useEffect(fn, []) as "componentDidMount." A more accurate mental model is: this effect keeps the outside world in sync with these dependencies. When the dependencies change, React cleans up the old effect and runs a fresh one. Thinking in terms of synchronization — not lifecycle events — prevents most useEffect bugs.

Effect lifecycle across renders On first render the effect runs setup. On a re-render where dependencies change, React runs cleanup then setup again. On unmount it runs cleanup. First render run setup() effect is active Deps changed run cleanup() then setup() Unmount run cleanup() effect is gone
Figure 2 — Every effect can run setup and later cleanup. React pairs them: before re-running or unmounting, it always cleans up the previous run first.

Cleanup: Preventing Leaks

If your effect creates something that outlives a single render — an event listener, a timer, a subscription — you must tear it down. Return a cleanup function from the effect and React will call it before the next run and before unmount.

import { useState, useEffect } from 'react';

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

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

    window.addEventListener('resize', handleResize);

    // Cleanup: remove the listener so it doesn't pile up
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Set up once, tear down on unmount

  return <p>{size.width} × {size.height}</p>;
}

📖 When cleanup runs

Before every re-run of the effect (when a dependency changed) and once when the component unmounts. If you skip cleanup, each run stacks a new listener/timer on top of the old one — the classic memory leak and "why is this firing five times?" bug.

A subtle but important detail: each effect run gets its own cleanup. The cleanup closes over the values from the render that created it, so it always removes exactly the listener it added — never a mismatched one.

Timers are the textbook case

function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [running, setRunning] = useState(false);

  useEffect(() => {
    if (!running) return; // No timer needed while paused

    const id = setInterval(() => {
      // Functional update: no need to list `seconds` as a dependency
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(id); // Stop the old interval before restarting
  }, [running]);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={() => setRunning(r => !r)}>
        {running ? 'Pause' : 'Start'}
      </button>
    </div>
  );
}

Notice the functional update setSeconds(s => s + 1). Because it reads the latest state from React rather than from the closure, we don't have to put seconds in the dependency array — which would otherwise recreate the interval every tick.

Data Fetching & Race Conditions

Fetching in an effect is common, but there's a trap. If a prop like userId changes quickly, an older request can resolve after a newer one, overwriting fresh data with stale data. This is a race condition.

The fix is to mark the effect as "ignored" during cleanup, or to cancel the request with an AbortController:

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [status, setStatus] = useState('loading'); // 'loading' | 'error' | 'ready'

  useEffect(() => {
    let ignore = false;           // Guards against out-of-order responses
    const controller = new AbortController();
    setStatus('loading');

    async function load() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        if (!ignore) {
          setUser(data);
          setStatus('ready');
        }
      } catch (err) {
        if (err.name !== 'AbortError' && !ignore) setStatus('error');
      }
    }

    load();

    // Cleanup: this render's fetch is now stale — ignore it and abort
    return () => {
      ignore = true;
      controller.abort();
    };
  }, [userId]);

  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'error') return <p>Could not load user.</p>;
  return <h2>{user.name}</h2>;
}

💡 In real apps, reach for a data library

Manual fetching in useEffect teaches the fundamentals, but production apps usually use a dedicated data layer — TanStack Query (React Query), SWR, or a framework loader (Next.js, React Router). These handle caching, deduplication, retries, and race conditions for you. Learn the raw pattern first so you understand what those libraries are doing under the hood.

⚠️ Effects run twice in development

With React 18+ Strict Mode, React intentionally mounts, unmounts, and remounts each component once in development. That means your effect (and its cleanup) run twice. This is a feature: it surfaces missing cleanup. Write effects that are safe to run twice — the ignore/AbortController pattern above already is.

Common Pitfalls

1. The infinite loop

An effect that updates a value it also depends on will re-run forever:

// ❌ Infinite loop: setting `count` re-triggers the effect that sets `count`
useEffect(() => {
  setCount(count + 1);
}, [count]);

Fix it by removing the dependency (run once), using a functional update, or guarding with a condition:

// ✅ Runs once
useEffect(() => {
  setCount(1);
}, []);

2. Lying about dependencies

Leaving a value out of the array to "make it run less" creates a stale closure — the effect captures an old value and silently misbehaves. Include everything the effect reads, and let the eslint-plugin-react-hooks rule check you.

SymptomLikely causeFix
Effect fires every renderNo dependency arrayAdd [] or the real deps
Uses an old prop/state valueMissing dependency (stale closure)List it; use functional updates
Runs endlesslyEffect changes its own dependencyFunctional update or guard condition
Timer/listener duplicatesMissing cleanupReturn a cleanup function

3. Reaching for useEffect when you don't need it

Modern React guidance: you might not need an effect. Data you can compute during render (a filtered list, a derived total) should be computed directly — not stored in state and synced with an effect. Save useEffect for genuine outside-the-component synchronization.

📖 Rule of thumb

If the effect's only job is to update React state from other React state, delete it and compute the value inline (optionally with useMemo). Effects are for the network, subscriptions, timers, and the DOM — not for reacting to your own state.

Hands-on: Live Search with Debounce

🏋️ Build it

Objective: A search input that queries an API as the user types, but (a) waits until typing pauses (debounce) and (b) never lets a stale response overwrite a newer one.

Requirements:

  1. One effect debounces the raw input into a debouncedTerm after 400 ms.
  2. A second effect fetches results whenever debouncedTerm changes.
  3. The fetch effect guards against race conditions and shows a loading state.
  4. Empty input clears the results without hitting the network.
💡 Hint

Split the two concerns into two separate useEffect calls. The debounce effect returns clearTimeout as cleanup so each keystroke cancels the previous timer. The fetch effect uses an ignore flag like the profile example above.

✅ Solution
import { useState, useEffect } from 'react';

function LiveSearch() {
  const [term, setTerm] = useState('');
  const [debounced, setDebounced] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // Effect 1 — debounce the raw input
  useEffect(() => {
    const id = setTimeout(() => setDebounced(term), 400);
    return () => clearTimeout(id); // cancel on the next keystroke
  }, [term]);

  // Effect 2 — fetch when the debounced term settles
  useEffect(() => {
    if (!debounced.trim()) {
      setResults([]);
      return;
    }

    let ignore = false;
    const controller = new AbortController();
    setLoading(true);

    async function search() {
      try {
        const res = await fetch(
          `https://api.github.com/search/users?q=${encodeURIComponent(debounced)}`,
          { signal: controller.signal }
        );
        const data = await res.json();
        if (!ignore) setResults(data.items ?? []);
      } catch (err) {
        if (err.name !== 'AbortError' && !ignore) setResults([]);
      } finally {
        if (!ignore) setLoading(false);
      }
    }

    search();
    return () => {
      ignore = true;
      controller.abort();
    };
  }, [debounced]);

  return (
    <div>
      <input
        value={term}
        onChange={(e) => setTerm(e.target.value)}
        placeholder="Search GitHub users…"
      />
      {loading && <p>Searching…</p>}
      <ul>
        {results.map((u) => (
          <li key={u.id}>{u.login}</li>
        ))}
      </ul>
    </div>
  );
}

Try it against the live GitHub API — it needs no key for light use. Notice how fast typing produces exactly one request per pause, and how switching queries never shows results from an old one.

Best Practices

✅ Do

  • Declare a dependency array on nearly every effect, and list every value the effect reads.
  • Return a cleanup function for anything that subscribes, listens, or schedules.
  • Use functional state updates (setX(prev => …)) to avoid needless dependencies.
  • Split unrelated concerns into separate effects — one job per effect.
  • Guard async work with an ignore flag or AbortController.

❌ Don't

  • Don't omit a dependency to "fix" a loop — fix the logic instead.
  • Don't use an effect to derive state you can compute during render.
  • Don't async the effect callback directly; define an async function inside and call it.
  • Don't forget cleanup — Strict Mode's double-run in dev exists to catch exactly this.

Summary & Quiz

🎉 Key Takeaways

  • Side effects are anything outside pure render — fetching, subscriptions, timers, DOM work.
  • The dependency array controls when an effect re-runs: omitted (every render), [] (once), or [deps] (on change).
  • A returned cleanup function runs before re-run and on unmount — essential for listeners and timers.
  • Guard async fetches against race conditions with an ignore flag or AbortController.
  • Think of effects as synchronization, not lifecycle events — and skip them when a value can be derived during render.

🎯 Quick Quiz

Question 1: An effect written as useEffect(fn, []) runs…

Question 2: Why return a cleanup function from an effect that adds a resize listener?

Question 3: Which pattern prevents a stale API response from overwriting newer data?

📚 Further Reading

🚀 What's Next?

When state grows tangled — many interrelated fields, many ways to update it — scattered useState calls become hard to reason about. Next we'll centralize that logic with useReducer, a structured, testable way to manage complex state.

🎉 Effects unlocked!

You can now connect React to the outside world safely. On to complex state.