useEffect Hook

useEffect is the hook React gives you to run side effects inside a function component: fetching data, subscribing to an event, starting a timer, manually changing the DOM, or logging. Anything that reaches outside the pure render-and-return-JSX flow belongs in an effect. Without it, function components would have no clean way to say "after this renders, also do this."

Effects run after React has painted the screen, and you control exactly when they re-run using a dependency array. Getting that array right is the single most important skill for using useEffect correctly, so this lesson spends real time on it.

Overview / How It Works

React’s render phase must stay pure: given the same props and state, a component function should return the same JSX, with no side effects like network calls or subscriptions happening inline in the function body. But real apps need side effects. useEffect is the escape hatch — it lets you schedule code to run after the render is committed to the DOM, separate from the pure rendering logic.

Every render, React compares the dependency array you pass to useEffect against the array from the previous render. If any value in the array changed (compared with Object.is), React runs your effect function again after this render’s DOM updates are committed. If nothing changed, React skips running the effect and keeps whatever the last effect set up.

The dependency array has three common shapes:

  • Omitted entirely — the effect runs after every single render.
  • An empty array [] — the effect runs once, right after the first render (mount), and never again due to state/prop changes.
  • An array with values [a, b] — the effect runs after the first render, and again any time a or b changes between renders.

An effect function can optionally return a cleanup function. React calls that cleanup function before running the effect again, and once more when the component unmounts. This is how you cancel subscriptions, clear timers, or abort in-flight requests so they don’t leak or run against a component that no longer exists.

Because effects run after the browser has painted, they never block visual updates the way synchronous work in the render body would. This is different from lifecycle methods in class components, which is covered in the dedicated Class Components lesson for historical context — in modern React, useEffect (and its sibling useLayoutEffect for the rare cases needing pre-paint DOM measurements) replaces componentDidMount, componentDidUpdate, and componentWillUnmount combined.

Syntax

useEffect(() => {
  // effect logic — runs after render

  return () => {
    // optional cleanup — runs before the next effect, and on unmount
  };
}, [dependency1, dependency2]);
Part Meaning
First argument A function containing the side-effect code. Can return a cleanup function or nothing.
Cleanup function Optional. Returned from the effect function; runs before re-running the effect and on unmount.
Second argument The dependency array. Controls when the effect re-runs. Omit it, pass [], or pass [values].

Examples

Example 1: Updating the document title

import { useState, useEffect } from "react";

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

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);

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

export default Counter;

Renders: a button reading "Clicked 0 times". Each click increments count and re-renders the button text; the effect then runs and updates the browser tab’s title to match.

The dependency array [count] tells React to re-run the effect only when count changes — not on unrelated re-renders (say, from a parent re-rendering for other reasons). This keeps the side effect (touching document.title, which lives outside React’s render tree) synchronized with exactly the state it depends on.

Example 2: Fetching data with a cleanup guard

import { useState, useEffect } from "react";

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

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    async function fetchUser() {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      const data = await response.json();
      if (!cancelled) {
        setUser(data);
        setLoading(false);
      }
    }

    fetchUser();

    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <h2>{user.name}</h2>;
}

export default UserProfile;

Renders: "Loading…" immediately, then the fetched user’s name once the request resolves. If userId changes before the first request finishes, the cleanup sets cancelled to true so the stale response is ignored instead of overwriting newer data.

Note that useEffect cannot take an async function directly (React expects the effect to return either nothing or a cleanup function, not a Promise), so the async logic is wrapped in an inner fetchUser function that is then called synchronously inside the effect.

Example 3: A timer with proper cleanup

import { useState, useEffect } from "react";

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

  useEffect(() => {
    if (!running) return;

    const id = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    return () => clearInterval(id);
  }, [running]);

  return (
    <div>
      <p>Seconds: {seconds}</p>
      <button onClick={() => setRunning((r) => !r)}>
        {running ? "Pause" : "Resume"}
      </button>
    </div>
  );
}

export default Stopwatch;

Renders: a live counter starting at "Seconds: 0" that increments every second, with a Pause/Resume button. Clicking Pause stops the interval; clicking Resume starts a fresh one.

This example uses the functional updater form setSeconds((prev) => prev + 1) instead of setSeconds(seconds + 1). That matters here: because the effect only re-runs when running changes, the interval callback closes over whatever seconds was at the time the effect ran. Using the updater function avoids reading that stale value and always increments from the latest state.

How It Works Step by Step

  • On mount: React renders the component, commits the resulting DOM changes, then runs every useEffect whose dependency array is new (which, on first render, is all of them) in the order they’re declared.
  • On update: React re-renders the component, commits changes to the DOM, compares each effect’s dependency array to its previous values, and — for any effect where a dependency changed — first calls that effect’s cleanup function (if any) from the previous run, then runs the effect function again.
  • On unmount: React calls the cleanup function of every effect currently active, in reverse order of how they were set up, before removing the component from the tree.

Common Mistakes

Mistake 1: Omitting a dependency that the effect actually uses.

function SearchResults({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then(setResults);
  }, []); // BUG: query is used but missing from the array

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

Because query is missing from the dependency array, the effect only fires once on mount and never re-fetches when the user types a new search term — the component silently shows stale results. Fix it by including every reactive value the effect reads:

useEffect(() => {
  fetch(`/api/search?q=${query}`)
    .then((res) => res.json())
    .then(setResults);
}, [query]);

Mistake 2: Forgetting cleanup on a subscription, causing leaks and duplicate work.

useEffect(() => {
  const id = setInterval(() => console.log("tick"), 1000);
  // BUG: no cleanup — a new interval starts on every re-run
  // and the old one never stops
}, [someValue]);

Each time someValue changes, a brand-new interval is created on top of the old one, which is never cleared. Soon multiple intervals are firing simultaneously. Always return a cleanup function for anything that persists past the effect call: return () => clearInterval(id);.

Best Practices

  • Include every value from component scope that the effect reads (state, props, and functions defined in the component) in the dependency array — don’t rely on omitting values to control timing.
  • Always return a cleanup function for subscriptions, timers, and event listeners set up inside an effect.
  • Split unrelated side effects into separate useEffect calls rather than combining everything into one — it keeps each effect’s dependency array meaningful.
  • Use the functional updater form of a state setter (setCount((c) => c + 1)) inside effects and intervals to avoid depending on a state value just to read it.
  • Guard async work with a cancellation flag (or AbortController) so a slow response arriving after the component’s inputs changed doesn’t overwrite newer data.
  • Don’t use useEffect for logic that can be computed directly during render — a derived value like const full = first + " " + last doesn’t need an effect at all.

Practice Exercises

  • Build an OnlineStatus component that adds a window event listener for "online" and "offline" events on mount, stores connectivity in state, and removes the listeners in the cleanup function.
  • Take the UserProfile example from this lesson and add an AbortController so the fetch is actually cancelled (not just ignored) when userId changes before the response arrives.
  • Write a component with two separate useEffect calls: one that logs to the console every time a count prop changes, and another that sets up a document.title update only once on mount. Verify each runs at the expected time.

Summary

  • useEffect runs side effects after React commits render output to the DOM.
  • The dependency array controls timing: omitted runs every render, [] runs once on mount, [a, b] reruns when a or b changes.
  • Returning a cleanup function from the effect lets you cancel subscriptions, timers, and stale requests before the next run and on unmount.
  • Missing dependencies cause stale data bugs; missing cleanup causes leaks and duplicated side effects.
  • Use the functional updater form of state setters inside effects to avoid stale closures over state values.