Under the Hood
Frontend

useEffect discipline: when it's the right tool

useEffect exists for exactly one job — synchronizing your component with something outside React's control, like a subscription or a WebSocket — and not as a generic "run this after render" hook. This lesson works through the classic misuse (deriving state with an effect instead of computing it during render), the correct use (a subscription with real cleanup), why skipping cleanup is a silent, accumulating bug rather than a style nit, and the dependency-array footgun that quietly feeds an effect stale data.

useEffect discipline: when it's the right tool

useEffect is the hook new React code reaches for most often, and it's also the one most often reached for wrong. The API looks harmless — a function, a dependency array, run some code when things change — and that shape is general enough to make it feel like the right place to put almost anything: recompute a value, respond to a prop change, kick off some logic after a click. Most of that code works, in the sense that it produces the right screen eventually. It's still wrong, because "eventually" is doing a lot of hiding.

Here's the litmus test for the whole lesson: useEffect exists to synchronize your component with something outside React — a subscription, a timer, a DOM API, a WebSocket, browser storage, anything that has a lifecycle React doesn't manage and doesn't know about. It does not exist as a general-purpose "run this after render" hook. Every anti-pattern in this lesson traces back to treating it as the latter.

The map: first, the wrong example in full — computing a derived value with an effect — and precisely why it costs a render and a moment of visible staleness even though it "works." Then the right example in full — a WebSocket subscription — which is the exact shape the litmus test describes. Then why skipping that example's cleanup function isn't untidiness but a real bug that gets worse the longer the component lives. And finally the dependency-array footgun, which is the same stale-closure mechanism from the previous lesson showing up inside an effect instead of an event handler.

The wrong example: deriving state with an effect

Say a component receives firstName and lastName as props and needs to display the full name. Written the way useEffect's shape invites, it looks like this:

function Profile({ firstName, lastName }) {
  const [fullName, setFullName] = useState('');

  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);

  return <h1>{fullName}</h1>;
}

This renders the right name on screen, eventually, and a lot of code that looks exactly like this ships and works. But walk through what actually happens on, say, the first render after firstName changes from "Ada" to "Grace":

  1. React re-renders Profile with the new props. fullName state hasn't changed yet — it still holds "Ada Lovelace" from before. React commits this render to the DOM. The user's screen briefly shows the old name next to whatever else on the page already reflects the new props.
  2. After that commit, React runs the effect, because effects run after paint. The effect calls setFullName('Grace Hopper').
  3. That setState schedules a second render. React re-renders Profile again, this time with the correct fullName, and commits that.

Two renders, one visible flash of stale data, to compute a value that was sitting right there in props the entire time. The fix removes the effect, the state, and the extra render in one move:

function Profile({ firstName, lastName }) {
  const fullName = firstName + ' ' + lastName;
  return <h1>{fullName}</h1>;
}

No useState, no useEffect, no dependency array to keep in sync with the computation. fullName is computed fresh every render, directly from the props that determine it, so it is never stale — there's no window where the component has committed with an out-of-date derived value, because there's no separate piece of state that could lag behind its source. This generalizes past string concatenation: a filtered list computed from an items prop and a searchTerm state value has the same shape and the same fix — const filtered = items.filter(i => i.includes(searchTerm)) in the render body, not an effect that watches both and calls setFilteredItems. If a value can be computed from props and state you already have, computing it during render is strictly better than deriving it in an effect: it's synchronous, correct on the first render, and half the rendering work.

The right example: a WebSocket has its own lifecycle

Now the case the litmus test is actually describing. A WebSocket connection isn't a value you can compute from props during render — it's a stateful object with its own lifecycle: it opens, it stays open independently of whether your component happens to be rendering at any given moment, it receives messages on its own schedule, and it has to be explicitly closed or it keeps running. That's precisely "something outside React" — React didn't create the connection by rendering, and it can't tear it down by rendering either. Something has to own connecting and disconnecting it in sync with the component, and that something is a useEffect.

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const socket = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);

    socket.addEventListener('message', (event) => {
      const message = JSON.parse(event.data);
      setMessages((prev) => [...prev, message]);
    });

    socket.addEventListener('open', () => {
      console.log(`Connected to room ${roomId}`);
    });

    return () => {
      socket.close();
    };
  }, [roomId]);

  return <MessageList messages={messages} />;
}

Two things make this the correct use, not just a correct-looking one. First, the effect body's job is entirely about the outside-React thing — opening a connection tied to roomId — and only incidentally updates React state as a side effect of messages arriving; it isn't computing a value that props already contained. Second, and just as important, it returns a cleanup function that actually closes the socket. That return value is not optional bookkeeping — it's the other half of "synchronize," the half that tears down the old connection before a new one opens or before the component goes away. AbortController: cancelling what you no longer need is this exact same shape applied to fetch instead of a socket: open something outside React in the effect body, close it in the effect's cleanup, so the outside thing's lifetime tracks the component's rather than outliving it.

Why skipping cleanup is a bug, not a style nit

It's tempting to treat the cleanup function as optional polish — the component still "works" without it, in the sense that messages still show up. Walk through what actually happens across a few roomId changes with the return () => socket.close() line deleted.

roomId starts at "general". The effect runs, opens a socket to "general", and wires up a message handler. The user switches to "random". roomId changes, so the effect's dependency array trips, and React re-runs the effect — but with no cleanup function, React never called anything to close the previous socket first. A second socket, to "random", opens. The first one, to "general", is still open, still connected, and its message handler — a closure over the setMessages call from that render — is still live. The user switches again, to "off-topic". Now there are three open sockets, all still receiving messages from three different rooms, all still calling setMessages on every message they get.

The visible symptom isn't a crash. It's messages from rooms the user already left continuing to appear in a window that's supposed to show only the current room — and a quick manual test gives no hint that the second and third switch each left a connection running underneath, since the current room's messages still look fine. Left alone for a long session, a user who switches rooms twenty times ends up with twenty live sockets, twenty sets of network resources the browser is still holding open, and message handlers firing at a rate that grows with how long the component has been mounted rather than with anything the user is currently doing. That's a slow-motion leak: easy to miss in a quick check, and exactly the kind of bug that shows up as "the app gets slower and weirder the longer you use it" days later, tied to nothing an isolated bug report would obviously point to.

That diagram is what's supposed to happen: cleanup always runs on the previous effect before the new effect body runs, every single time a dependency changes, and again once more when the component unmounts. Delete the cleanup function and every "call cleanup" step in that diagram just doesn't happen — sockets A and B never close, and the diagram's clean one-socket-at-a-time picture becomes an ever-growing pile of open connections instead.

The dependency-array footgun

There's a second way to get this wrong that has nothing to do with forgetting cleanup: listing an incomplete dependency array. Stale closures in React covers the underlying mechanism in full — a function closes over the values a variable held during the render that created it, not whatever that variable holds later — and everything in this section is that same mechanism, applied to an effect body instead of an event handler.

The footgun is precise: omitting a value from the dependency array that the effect body actually reads doesn't make the effect stop depending on that value. It only stops the effect from re-running when that value changes. The closure the effect body captured is still frozen at whatever that value was during the render that created it, exactly the way the previous lesson described for closures in general — the dependency array just controls the schedule on which a stale effect keeps running, not whether it's stale.

Concretely: a ChatRoom component tracks whether the user is muted with a muted state value, and the effect's message handler checks it before appending a message:

useEffect(() => {
  const socket = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);

  socket.addEventListener('message', (event) => {
    if (!muted) {
      setMessages((prev) => [...prev, JSON.parse(event.data)]);
    }
  });

  return () => socket.close();
}, [roomId]); // muted is read above but missing here

The effect only re-runs when roomId changes, so once the user toggles mute on, nothing re-runs this effect — the socket that's already open keeps calling the same message handler it registered when it first connected, and that handler's muted is permanently the value it closed over back then: false. The user mutes the room and messages keep appending anyway, because the check that's supposed to stop them is reading a variable frozen at "not muted," forever, until roomId happens to change for an unrelated reason and the effect re-runs with a fresh closure that finally sees the current muted. There is no error and no warning from React by default — the code runs exactly as written, silently operating on outdated data.

The two failure modes in this lesson compound if you get both wrong at once — a missing cleanup and a missing dependency — but they're separate bugs with separate fixes: cleanup governs whether an old outside-React resource actually stops when a new one starts; the dependency array governs whether the effect body is reading current values or values frozen from an earlier render. Getting the litmus test right up front — is this actually synchronizing with something outside React, or is it a value I could just compute — is what keeps you from needing either fix in the first place, because most code that never should have been an effect doesn't have a meaningful dependency array or cleanup story to get right or wrong.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. In the fullName example, walk through exactly why the first render after a prop change shows a stale value — which render commits first, and what triggers the second one?
  2. Why does computing a derived value directly in the render body avoid the extra-render problem that useEffect plus useState has, even though both eventually show the same correct value?
  3. What specific property of a WebSocket connection makes it the kind of thing useEffect is meant to manage, as opposed to a value like a filtered list?
  4. A component's effect opens a subscription but has no cleanup function. After the component's dependency changes three times, how many live subscriptions exist, and why doesn't a quick manual test usually reveal this?
  5. An effect's dependency array omits a state variable that the effect body reads inside a callback. Does the effect stop depending on that variable? What actually happens instead?
  6. What does the exhaustive-deps rule in eslint-plugin-react-hooks actually check for, and what class of bug does it not catch — specifically, whether an effect should exist at all?
  7. Compare the AbortController cleanup pattern from the previous track lesson to the socket.close() cleanup here — what's the same about what each one is solving, and what's different about the resource being torn down?