Effects and Data Fetching

useEffect, dependency arrays, cleanup functions, and fetching data with loading and error states.

Why effects exist

Rendering a component should be a pure calculation: given the same props and state, it returns the same JSX, with no side effects along the way. But real applications need to do things that aren't pure — fetching data from a server, subscribing to a WebSocket, manually setting document.title, starting a timer. These are called side effects, and React gives you a dedicated hook for them so they stay clearly separated from rendering logic: useEffect.

useEffect basics

Javascript
import { useEffect, useState } from "react";

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

  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]); // re-run only when `count` changes

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

useEffect takes two arguments: a function to run (the effect itself), and a dependency array that controls when it re-runs.

The dependency array, precisely

Dependency array When the effect runs
Omitted entirely After every render
[] (empty) Once, after the first render only
[a, b] After the first render, and again whenever a or b changes between renders
Javascript
useEffect(() => {
  console.log("Runs after every render");
});

useEffect(() => {
  console.log("Runs once, on mount");
}, []);

useEffect(() => {
  console.log("Runs on mount, and again whenever userId changes");
}, [userId]);

React compares each dependency to its value from the previous render using Object.is (essentially ===). This is exactly why the array must include every reactive value the effect reads — every prop, state variable, or function defined in the component that the effect's body uses. Leaving one out is the single most common React bug: the effect silently keeps using a stale, captured value from whichever render it last ran in, instead of the current one.

Javascript
// Bug: `userId` is used inside the effect but missing from the dependency array
function Profile({ userId }) {
  useEffect(() => {
    fetchUser(userId).then(/* ... */); // stale `userId` if the prop ever changes
  }, []); // should be [userId]
}

The eslint-plugin-react-hooks linter's exhaustive-deps rule catches this automatically in most project setups — treat its warnings as bugs, not noise.

Cleanup functions

If an effect sets something up — an event listener, a timer, a subscription — it usually needs to tear that thing down too, or it'll pile up duplicates every time the effect re-runs, and leak once the component unmounts. Do that by returning a function from the effect:

Javascript
import { useEffect, useState } from "react";

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize); // cleanup
    };
  }, []); // set up once, tear down once, on unmount

  return <p>Window width: {width}px</p>;
}

React calls the cleanup function in two situations: right before the effect runs again (if dependencies changed), and when the component unmounts. This is what makes the pattern safe to reuse for anything periodic:

Javascript
import { useEffect, useState } from "react";

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    return () => clearInterval(intervalId); // clear the old interval before any re-run, and on unmount
  }, []);

  return <p>Elapsed: {seconds}s</p>;
}

Without the cleanup function here, every time this effect re-ran you'd stack up an additional interval still ticking in the background — a classic source of memory leaks and duplicated side effects in React apps.

Data fetching with loading and error states

Fetching data is the most common real-world use of useEffect. A production-quality fetch tracks at least three states: loading, error, and the data itself.

Javascript
import { useEffect, useState } from "react";

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

  useEffect(() => {
    let cancelled = false; // guards against updating state after unmount

    async function loadUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error(`Request failed: ${response.status}`);
        }
        const data = await response.json();
        if (!cancelled) {
          setUser(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    loadUser();

    return () => {
      cancelled = true; // if userId changes again before this finishes, ignore its result
    };
  }, [userId]);

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

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

A few details worth calling out:

  • useEffect's function cannot be async itself (an async function returns a Promise, not a cleanup function, which React would call incorrectly) — so the async logic is wrapped in an inner function that's declared and then immediately invoked instead.
  • The cancelled flag prevents a classic race condition: if userId changes again while the first request is still in flight, the first request's result is discarded instead of overwriting the second, more relevant one when it eventually resolves.
  • All three states — loading, error, and user — are reset appropriately at the start of each fetch, so switching to a new userId shows a fresh loading state rather than briefly displaying the previous user's stale data.

In a real production app, this pattern is usually wrapped up by a data-fetching library like TanStack Query or SWR, which handles caching, retries, and race conditions like this automatically — but understanding the raw useEffect version is what makes those libraries make sense.

Common mistakes

  • Omitting a value the effect actually uses from the dependency array, causing it to run with a stale, captured value instead of the current one.
  • Returning nothing from an effect that sets up a listener, timer, or subscription — leaking it every time the effect re-runs or the component unmounts.
  • Making the effect callback itself async (useEffect(async () => {...}, [])) instead of defining an inner async function and calling it.
  • Forgetting the "is this still relevant" cancellation check in a fetch effect, letting an old, slow request overwrite a newer one's state.