React Hooks & Rendering
React Hooks & Rendering

useEffect — and when not to use it

Full · ~35 min
After this unit you can
  • Explain that effects exist to sync with systems outside React, like a socket, timer, or the network
  • Write a subscription effect whose cleanup and deps reconnect cleanly when a prop like roomId changes
  • Predict when an effect runs relative to render, and the stale value an empty-deps interval reads
  • Fix a fetch effect's race and cancel stale requests by aborting the previous request with AbortController in cleanup, telling that AbortError apart from a real failure

Read

Simple
1 / 2

Don't derive state in an effect

Flag

Rule of thumb: if you can compute it during render, don't put it in an Effect.

// ❌ Don't: derive state with an Effect
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(first + " " + last);
}, [first, last]);

// ✅ Do: just compute it during render
const fullName = first + " " + last;

The Effect version renders once with a stale value and then re-renders. The plain version is correct on the very first render and does no extra work.

Medium

Effects that start something must clean it up

Flag

Effects need cleanup whenever they start something that must later be stopped: a subscription, a timer, an event listener. The cleanup runs before the next effect and on unmount.

useEffect(() => {
  const conn = createConnection(roomId);
  conn.connect();
  return () => conn.disconnect(); // runs on roomId change + on unmount
}, [roomId]);

Forget the cleanup and you leak a connection every time roomId changes. The dependency array is a claim: "re-run this effect only when these values change."

In development, StrictMode (React 18+) checks this for you: on mount it runs one extra setup + cleanup cycle, so you'll see connect → disconnect → connect. With a correct cleanup that's invisible to the user; without one, you'd see two live connections straight away.

Complex

Fetching in an effect — guard races and cancel stale requests

Flag

Fetching inside an Effect has two classic hazards:

  1. Race conditions — a slow earlier request resolves after a newer one and overwrites its results.
  2. Requests nobody needs — the query changed or the component unmounted, but the old request keeps running, using bandwidth and server time for a response you'll throw away.

Guard both with AbortController:

useEffect(() => {
  const controller = new AbortController();
  fetchResults(query, { signal: controller.signal })
    .then((results) => {
      setResults(results);
      setError(null);
    })
    .catch((err) => {
      if (err.name === "AbortError") return; // we cancelled it in cleanup: expected
      setError(err); // a real failure: put it on screen
    });
  return () => controller.abort();
}, [query]);

Don't throw err inside that .catch to "surface" a real error. A throw there only rejects the promise chain, which ends as an unhandled promise rejection in the console; it never reaches React, so an error boundary can't catch it. Put the error in state and render it.

This is the exact bug you fix in the challenge at the end of this unit. In real apps, prefer a data-fetching library (TanStack Query) or your framework's data loading, which handle this for you — but understand why it's needed.

Quiz

Which of these genuinely needs a useEffect?

Flag

What does the dependency array [roomId] actually promise React?

Flag

A search effect fetches results for query and calls setResults when the response arrives. What must its cleanup do?

Flag

Fill in the blank

Complete the subscription effect: subscribe on mount, re-subscribe when roomId changes, and clean up to avoid leaks.

Flag
useEffect(() => {
  const conn = createConnection(roomId);
  conn.connect();
  return () => conn.();
}, []);

Complete the search effect so a query change or an unmount cancels the request still in flight.

Flag
useEffect(() => {
  const controller = new ();
  fetch(`/api/search?q=${encodeURIComponent(query)}`, { : controller.signal })
    .then((res) => res.json())
    .then(setResults)
    .catch((err) => {
      if (err.name !== "AbortError") setError(err);
    });
  return () => controller.();
}, [query]);

Predict the output

function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []);
  return <h1>{count}</h1>;
}
Flag
// Production build (no StrictMode double-calls)
function App() {
  const [n, setN] = useState(0);
  console.log('render', n);
  useEffect(() => {
    console.log('effect', n);
  });
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}
Flag

Challenge

Fix the search-as-you-type effect

Flag

SearchUsers fetches results whenever the query changes. It works for a single search, but it has three bugs that show up the moment requests overlap or fail:

  • Race condition: if an earlier (slower) request resolves after a newer one, the stale results overwrite the fresh ones.
  • No cancellation: in-flight requests keep running after the query changes or the component unmounts, wasting network and server work on responses nobody will use.
  • Silent failures: a request that fails is never caught. The user sees nothing, and the console gets an unhandled promise rejection.

Show a failed search in an element with role="alert". A request you cancelled is not a failure, so it must not show up there.

Bonus: discuss why a library like TanStack Query removes the need to write this by hand.

Your task

Edit SearchUsers.tsx so the tests pass:

  • Typing a query shows that query's results
  • A slow earlier response never overwrites a newer query's results
  • The in-flight request is aborted when the query changes or the component unmounts
  • A failed search shows an error in a role="alert" element; a request you cancelled doesn't
Loading the in-browser editor…

Further reading