webdev.complete
🪝 State & Effects
⚛️React
Lesson 81 of 117
30 min

useEffect (and when NOT to)

Sync with external systems. Cleanup. Race conditions.

useEffect is the most misunderstood React hook. The official docs literally have a page called You Might Not Need an Effect, and most useEffect code in real codebases is wrong. Let's set the record straight.

What it's actually for

Use useEffect to synchronize your component with something outside React. Browser APIs, third-party libraries, network requests, subscriptions, timers. If you're not touching the world outside React, you almost certainly don't need an effect.

tsx
useEffect(() => {
  // 1. Setup: runs after the DOM commits
  document.title = `(${count}) Notifications`;

  // 2. Cleanup (optional): runs before the next effect or on unmount
  return () => {
    document.title = "App";
  };
}, [count]); // 3. Dependency array: re-run when these change
Not for derived data
If you find yourself writing useEffect(() => setFoo(bar.length), [bar]), delete it. Just compute const foo = bar.length during render. Effects for derived values are a bug: an extra render, stale values mid-render, and harder reasoning.

The infinite loop trap

Every useEffect beginner writes this at least once:

tsx
// 🔥 INFINITE LOOP 🔥
const [data, setData] = useState(null);

useEffect(() => {
  fetch("/api/data").then((r) => r.json()).then(setData);
}); // <- no dependency array! Runs after EVERY render.

// setData renders → effect runs → fetches → setData → renders → ...

Three forms of the dependency array, three meanings:

  • No array: run after every render. Usually a bug.
  • []: run once after mount, cleanup on unmount.
  • [a, b]: run when a or b changes (compared with Object.is).

Cleanup matters

Effects often start something that needs stopping. Subscriptions, intervals, listeners, animations. Return a function from your effect and React calls it before the next effect or when the component unmounts.

tsx
useEffect(() => {
  const id = setInterval(() => console.log("tick"), 1000);
  return () => clearInterval(id);
}, []);

Forgetting cleanup is the most common source of memory leaks and zombie subscriptions in React apps.

Fetching: use AbortController

Network requests started in an effect outlive the component. If the user clicks away before the response, you'll setData on an unmounted component (a warning) or overwrite newer data with older data (a real bug). Abort.

tsx
useEffect(() => {
  const ctrl = new AbortController();

  fetch(`/api/users/${id}`, { signal: ctrl.signal })
    .then((r) => r.json())
    .then(setUser)
    .catch((e) => {
      if (e.name === "AbortError") return; // expected
      console.error(e);
    });

  return () => ctrl.abort();
}, [id]);
But really, use TanStack Query
Manual fetch effects with abort, retries, caching, and dedupe are 50+ lines of code you'll get subtly wrong. In real apps, TanStack Query(or SWR) is the answer. We'll cover it in a later chapter.

StrictMode runs your effect twice

In development, React's <StrictMode> intentionally mounts, unmounts, and remounts every component to surface bugs in your effects. You'll see your fetch fire twice, your interval start twice, your console.log appear twice. This is nota bug. It's React shaking your code to make sure your cleanup works.

If StrictMode breaks your effect, your effect was already broken. Either it's missing a cleanup, or it has a side effect that shouldn't be in an effect at all. Production runs them once.

The "you might not need an effect" checklist

Before you write an effect, ask:

  • Computing from props/state? Just compute during render.
  • Resetting state when a prop changes? Use the key prop on the component instead.
  • Sharing logic between events? Extract a function and call it from each handler.
  • Notifying a parent of a change?Lift the state up. Call the parent's callback from the same handler that changes state.

Try it: a real fetch effect with cleanup

Click between users. The effect fetches whenever id changes, aborts the previous request, and cleans up. Open the console to see how StrictMode runs everything twice in dev.

import { useEffect, useState } from "react";

function UserCard({ id }) {
  const [user, setUser] = useState(null);
  const [status, setStatus] = useState("loading");

  useEffect(() => {
    console.log(`[effect] subscribing to user ${id}`);
    const ctrl = new AbortController();
    setStatus("loading");

    fetch(`https://jsonplaceholder.typicode.com/users/${id}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then((data) => {
        setUser(data);
        setStatus("ok");
      })
      .catch((e) => {
        if (e.name === "AbortError") {
          console.log(`[effect] aborted user ${id}`);
          return;
        }
        setStatus("error");
      });

    return () => {
      console.log(`[effect] cleanup for user ${id}`);
      ctrl.abort();
    };
  }, [id]);

  if (status === "loading") return <p>Loading user {id}...</p>;
  if (status === "error") return <p>Error loading user {id}</p>;
  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

export default function App() {
  const [id, setId] = useState(1);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui" }}>
      <p>Click rapidly to see aborts in the console:</p>
      {[1, 2, 3, 4].map((n) => (
        <button key={n} onClick={() => setId(n)} disabled={id === n} style={{ marginRight: 4 }}>
          User {n}
        </button>
      ))}
      <UserCard id={id} />
    </div>
  );
}

Quiz

Quiz1 / 3

When should you reach for useEffect?

Recap

  • Effects sync your component with external systems. They are not for derived values.
  • Three dep array meanings: omitted = every render, [] = once, [deps] = on change.
  • Return a cleanup function. Subscriptions, intervals, fetches all need one.
  • Cancel fetches with AbortController in cleanup to avoid race conditions.
  • StrictMode double-invokes effects in dev. If that breaks you, your cleanup is missing.
  • Read "You Might Not Need an Effect" on react.dev. Most uses have a better solution.