Effects: Dependencies and Cleanup

Understand what a useEffect call actually synchronizes, how its dependency array decides when it re-runs, and how a returned cleanup function prevents leaked timers, subscriptions, and stale network requests.

React components render UI from props and state, but real applications also need to talk to things outside that render output — a timer, a WebSocket, the browser's document title, an HTTP request. useEffect is React's tool for that: it lets a component synchronize itself with an external system, and its dependency array and cleanup function together define exactly when that synchronization starts, restarts, and stops.

What an Effect Actually Synchronizes

An effect is not 'code that runs after render' in some general sense — it's specifically for keeping something outside React (a DOM API, a subscription, a server connection) in sync with the current props and state. If a piece of logic doesn't touch anything outside the component, it almost certainly doesn't belong in an effect at all, a distinction the next lesson explores in depth.

By default, with no dependency array, an effect's function runs after every single render. That's rarely what you want for anything with real cost (opening a connection, starting a timer), so the dependency array exists to tell React exactly which reactive values the effect actually depends on, and to skip re-running the effect when none of them changed.

The effect function itself runs after the browser has painted the new DOM update to the screen, not synchronously during render — this ordering matters because it means effects can safely read the DOM (measuring an element, for example) without blocking the browser from showing the update first.

The Dependency Array in Detail

The dependency array is a list of every reactive value — props, state, and anything computed from them — that the effect's function reads. React compares each entry to its value from the previous render using Object.is; if every entry is unchanged, the effect is skipped this render, and if any entry changed, the effect (and its cleanup, if any) runs again.

Omitting a value the effect actually uses is the single most common source of bugs in effects, because the effect's function is a closure that captured that value from the render it was created in. If a variable is missing from the array, the effect keeps using the value it saw on the render where it was last (re-)created — a stale closure — even though the component's actual current value has moved on.

Object and array dependencies are a frequent trap: an object literal like { userId } created fresh in the component body is a new reference on every render, so listing it as a dependency causes the effect to re-run every single time even though its contents never logically changed — a classic cause of an effect that appears to loop infinitely, especially when the effect itself triggers a state update that causes another render.

An empty array, [], tells React there are no reactive dependencies at all, so the effect's setup runs once after the first render and its cleanup runs once when the component unmounts — nothing in between causes it to re-run.

Cleanup Functions and Their Timing

If the function passed to useEffect returns another function, React treats that returned function as cleanup. Cleanup runs right before the effect's setup runs again (when a dependency changed) and one final time when the component unmounts — which means for a component with dependencies, you actually get a repeating pattern of cleanup-then-setup on every relevant re-render, not just a single cleanup at the very end.

This is where a subscription is unsubscribed, a timer is cleared, or a socket is closed — anything the setup function started that would otherwise keep running (and keep referencing a now-abandoned closure) after the component moves on or disappears. Skipping cleanup for anything that outlives a single render is a direct path to leaked timers, duplicate event listeners piling up, or duplicate WebSocket connections.

For a WebSocket specifically, the pattern is symmetric: the setup function opens the connection and registers message handlers, and the cleanup function closes that same connection. If the dependency array includes something like a room ID, changing rooms correctly closes the old room's socket before opening a new one for the new room — exactly the behavior you want, driven entirely by the dependency array and cleanup working together.

Effects for Async Work: Fetching and AbortController

Fetching data in an effect looks straightforward — call fetch, store the result in state — but it hides a real bug: if the component re-renders with new dependencies (say, a changed search query) before the first request finishes, both requests are in flight, and whichever one resolves last overwrites the state, even if it's actually the stale, outdated response.

AbortController solves this by giving the cleanup function a way to cancel the in-flight request. The setup function creates a controller and passes controller.signal into fetch; the cleanup function calls controller.abort(). Because cleanup runs before the effect re-runs on a dependency change, the previous request is always cancelled before the new one starts, so a stale response can never overwrite a fresher one.

This pattern generalizes beyond fetch — any async operation that can outlive the render that started it (an async function updating state, a promise chain) needs some way for cleanup to signal 'ignore this if it resolves late,' whether that's an AbortController, a simple ignore boolean flag captured in the closure, or the async API's own cancellation mechanism.

useEffect with cleanup

The empty dependency array means this effect's setup and cleanup only run once, on mount and unmount respectively.

useEffect(() => {
  const id = setInterval(() => setTick(t => t + 1), 1000);
  return () => clearInterval(id);
}, []);

Code Examples

The interval is created once (empty deps) and cleared automatically when the component unmounts, preventing a leaked timer. Expected result: tick increments every second until the component unmounts.

roomId is listed as a dependency because the effect reads it to build the socket URL. Whenever roomId changes, React runs the cleanup (closing the old room's socket) before running the setup again (opening a socket for the new room), so the component never ends up connected to two rooms at once.

return () => socket.close();

Every time query changes, cleanup aborts the previous, now-irrelevant request before the new one starts, guaranteeing that only the response for the latest query can ever update state — even if an earlier request would otherwise have resolved later.

Common mistake: Without the AbortController, typing quickly into a search box can let an earlier request's slower response arrive after a later one and overwrite it, showing results for a query the user already changed away from.

useEffect(() => {
  const id = setInterval(() => setTick(t => t + 1), 1000);
  return () => clearInterval(id);
}, []);
function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
    socket.addEventListener("message", (e) => {
      setMessages(prev => [...prev, e.data]);
    });
    return () => socket.close();
  }, [roomId]);

  return <ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>;
}
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setResults(data.results))
      .catch(err => {
        if (err.name !== "AbortError") throw err;
      });

    return () => controller.abort();
  }, [query]);

  return <ul>{results.map((r, i) => <li key={i}>{r}</li>)}</ul>;
}

Effect lifecycle

Cleanup runs before the next effect and on unmount.

Render -> Commit -> Effect -> External System -> Cleanup -> Next Effect / Unmount

Important Rules

Include every reactive value the effect reads in the dependency array.

Always clean up subscriptions, timers, and listeners that outlive a single render.

Avoid depending on freshly created object or array literals — memoize them or depend on their primitive contents instead.

Cancel in-flight async work (via AbortController or an ignore flag) in the cleanup function so a stale response can't overwrite a fresher one.

Where this shows up

Every WebSocket connection, polling timer, and DOM event listener in a real React app depends on getting the dependency array and cleanup function right, or the app leaks connections and timers as users navigate around. Search-as-you-type features rely on AbortController inside an effect to keep results in sync with the latest keystroke rather than flickering back to an older, slower response.

useEffect(() => { subscribe(id); }, []); // missing id in deps useEffect(() => { subscribe(id); return () => unsubscribe(id); }, [id]);

Omitting id from the dependency array means the effect uses a stale closure over the first id forever, and skipping cleanup leaks the subscription.

useEffect(() => { doSomething({ userId }); // { userId } is a new object every render }, [{ userId }]);

useEffect(() => { doSomething({ userId }); }, [userId]); // depend on the primitive value instead

An inline object literal is a new reference on every render even when userId itself hasn't changed, so React sees the dependency as 'different' every time and re-runs the effect on every render — depending on the primitive userId instead fixes it.

[predict-output] What does an empty dependency array [] passed to useEffect mean? A. The effect never runs B. The effect runs after every render C. The effect runs once, after the first render D. The effect runs only on unmount

C. The effect runs once, after the first render — An empty array means there are no reactive dependencies to watch, so the effect runs exactly once, after the initial render.

[concept] Why does an effect's cleanup function run before the effect re-runs, not just on unmount? A. It doesn't — cleanup only ever runs once, on unmount B. So the previous setup (old subscription, old connection, old timer) is torn down before a new one for the changed dependencies is created C. Because React requires all effects to run exactly twice D. To reset the component's state back to its initial values

B. So the previous setup (old subscription, old connection, old timer) is torn down before a new one for the changed dependencies is created — Cleanup runs both before every re-run of the effect (when a dependency changed) and on unmount, so the old setup is always undone before a new one begins — preventing duplicate subscriptions, connections, or timers.

[debug] A search component's effect calls fetch(/api/search?q=${query}) with [query] as its dependency array but has no cleanup. What bug can this cause? A. The effect never re-runs when query changes B. A slower response to an earlier query can arrive after a faster response to a later query and incorrectly overwrite the results C. TypeScript will fail to compile the component D. The component will re-render infinitely

B. A slower response to an earlier query can arrive after a faster response to a later query and incorrectly overwrite the results — Without cancelling the previous request (via AbortController in a cleanup function), there's no guarantee responses resolve in the same order the requests were sent, so a stale response can overwrite fresher results.

Write a useEffect that adds a window resize listener on mount and removes it on unmount, storing the current window width in state.

useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []);

Return a cleanup function that calls window.removeEventListener with the same handler reference passed to addEventListener.