useEffect Explained: Synchronising with the Outside World

A deep-dive into useEffect — the correct mental model, the cleanup contract, stale closures, and when to reach for something else entirely.

useEffect Explained: Synchronising with the Outside World

Most React tutorials introduce useEffect with three lifecycle-flavoured examples: run something on mount, react to a change, clean up on unmount. That framing isn’t wrong — but it makes useEffect feel like componentDidMount dressed in a hook costume. And that mental model quietly causes bugs.

The model that actually works: useEffect is for synchronising a React component with something outside React. A browser API, a WebSocket connection, a third-party widget, a timer, a network request — anything React doesn’t own. The word synchronise matters here. React owns what it renders; useEffect is the bridge to everything else.

Once that idea clicks, most of useEffect’s behaviour stops feeling arbitrary.

Series: This post follows React Hooks You’ll Actually Use Every Day, which covers useState, useCallback, useMemo, useRef, and useContext. That post is where the basics live; this one goes deeper on useEffect specifically.

On this page

useEffect is a synchronisation mechanism, not a lifecycle hook

According to the React documentation, Effects let you specify side effects that are caused by rendering itself — not by a particular user event. That distinction matters more than it sounds.

Think about connecting to a chat room. That connection needs to happen whenever the ChatRoom component appears on screen — not because the user clicked something, but because rendering it is what made the connection necessary. That’s an Effect.

Contrast that with sending a message. That happens because a user clicked Send. It belongs in the click handler.

The mental model:

  • Rendering caused it? → Effect
  • A user action caused it? → Event handler

This also explains why useEffect re-runs after every render by default (unless you say otherwise). React is continuously asking: “is this component still in sync with the outside world?” After every render, it checks. The lifecycle framing misses this completely — it suggests three discrete moments rather than a continuous synchronisation loop.

If you’re new to effects, the React Fundamentals post covers the basics of components and state that this builds on.

The setup-and-cleanup contract

Here is the basic shape of a useEffect call:

useEffect(() => {
  // Setup: start, subscribe, connect, open
  const connection = createConnection(roomId);
  connection.connect();

  return () => {
    // Cleanup: stop, unsubscribe, disconnect, close
    connection.disconnect();
  };
}, [roomId]);

React calls your cleanup function in two situations: before re-running the effect (when dependencies change) and when the component unmounts. The contract: if you start something, stop it in the cleanup.

A missed cleanup is a resource leak. Old event listeners pile up, intervals keep firing after a component is gone, subscriptions deliver data into the void. Every “ghost” like this comes from a missing cleanup return.

The cleanup also explains something that confuses developers new to React’s Strict Mode. In development, React mounts every component twice — immediately remounting it after the initial mount. This is intentional. It stress-tests cleanup. If your effect breaks on remount, that is a real bug React is surfacing early. The fix is always the same: implement a proper cleanup function.

The dependency array: every reactive value must be listed

The second argument to useEffect controls when it re-runs:

useEffect(() => { /* runs after every render */ });

useEffect(() => { /* runs once, on mount */ }, []);

useEffect(() => { /* runs on mount + whenever a or b change */ }, [a, b]);

The React documentation is explicit about the rule: every value from the component that the effect reads — props, state variables, anything declared in the component body — must appear in the dependency array. Every single one.

You can’t choose your dependencies. They are determined by what the code inside the effect actually uses. The eslint-plugin-react-hooks lint rule enforces this automatically, and it is worth trusting.

The intuition behind it: if the effect uses a value and that value changes, the effect needs to re-run to stay in sync with the new reality. Omit a dependency and the effect will silently read a stale version of that value. This is the direct cause of the stale closure problem.

The stale closure trap

Because of how closures work in JavaScript, an effect captures the values from the scope where it was created — not where it eventually runs. Here is the classic example:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // captures "count" from this render
    }, 1000);
    return () => clearInterval(id);
  }, []); // runs only once

  return <p>{count}</p>;
}

The dependency array is [], so the effect runs once. The interval callback closes over count from the first render — which was 0. Every second it calls setCount(0 + 1). The counter never gets past 1.

Before you continue: What would happen if you added count to the dependency array? The effect would re-run every time count changed — clearing and recreating the interval on each tick. You’d swap one bug for another.

The clean fix is the functional updater form. Instead of reading count inside the callback, pass React a function that receives the current value:

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // no longer reads "count" at all
  }, 1000);
  return () => clearInterval(id);
}, []); // genuinely safe with empty deps

The React docs describe this exact fix: when an effect needs to update state based on its current value, use the updater form. That removes the dependency on the state variable entirely and the stale closure disappears.

The React Hooks post covers functional updaters in more depth alongside the useState section — worth reading together with this explanation if the distinction is new to you.

Fetching data with useEffect

Fetching in an effect is legitimate and common. The pattern React recommends uses an ignore flag to handle race conditions:

useEffect(() => {
  let ignore = false;

  async function fetchData() {
    const result = await fetchUser(userId);
    if (!ignore) {
      setUser(result);
    }
  }

  fetchData();

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

If userId changes before the first request finishes, the cleanup sets ignore = true and the stale response is discarded. Without it, a slow first response could overwrite a fast second one — a race condition that’s hard to reproduce but easy to ship.

The React documentation is honest about the trade-offs here: this approach works, but manual fetching in effects doesn’t run on the server, can create network waterfalls (a child component starts fetching only after its parent finishes), and caches nothing by default. For most production apps, a data-fetching library like TanStack Query or SWR — or a framework’s built-in mechanism — handles these concerns for you.

The effect-based pattern is reasonable for simple cases and fully client-side apps. For anything that needs caching, deduplication, or server rendering, reach for a library.

When not to use useEffect

The React documentation devotes an entire page to this, and it is worth reading in full. Two patterns come up constantly.

Deriving state from other state or props. This is the most common mistake:

// ❌ Unnecessary: computing fullName in an effect
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ✅ Calculate it during render — no effect needed
const fullName = firstName + ' ' + lastName;

The effect version renders twice: once with the stale fullName, then again after the effect fires and updates it. The inline version renders once, correctly. Any pure transform — sorting, filtering, computing a derived value — belongs at the top of the component, not inside an effect.

Responding to user events. If code should run because a user did something, it belongs in the event handler. The React docs illustrate this with a cart example: an effect watching product.isInCart to show a notification will re-fire on every page load, because product.isInCart is already true when the page loads again. That’s almost certainly wrong.

The guiding question: why does this code need to run? If the answer is “because the component appeared on screen,” that’s an effect. If the answer is “because the user pressed a button,” that’s an event handler.

Should I put this in useEffect?

Run through this before reaching for useEffect:

  • Is it caused by rendering itself — not by a specific user action? (If a button or form triggered it, use an event handler.)
  • Does it involve something outside React’s control? DOM APIs, subscriptions, timers, network connections — yes. Derived state or computed values — no.
  • Does it transform data that could just be calculated during render? (If so, move the calculation to the top of the component.)
  • Does it need cleanup? If you start something — a listener, a connection, a timer — return a cleanup function.
  • Is every reactive value it reads listed in the dependency array? If you’re suppressing the lint rule, reconsider.

If all the boxes check out, useEffect is the right tool. If one doesn’t, there’s almost always a cleaner solution: calculate during render, move to an event handler, or use useMemo for expensive computations.

Custom hooks are the clean way to reuse this

When you find yourself writing the same useEffect + useState pair in multiple components — a data-fetching setup, an event-listener pattern, a connection lifecycle — that’s the signal to extract it into a custom hook. A hook named useOnlineStatus or useEventListener hides the useEffect entirely. Callers get a clean, one-line API; the sync logic lives in one tested place.

Custom hooks are where useEffect becomes genuinely composable rather than repetitive boilerplate. That’s exactly what the next post covers.

Sources