React useEffect Explained: Dependencies, Cleanup and Infinite Loops

How useEffect really works: the dependency array, cleanup functions, the four causes of infinite loops, and effects you can delete.

By CrunchCubeUpdated 21 October 20265 min read

useEffect is the hook people use most and understand least. It's where infinite loops come from, where requests fire twice, and where "it works on my machine" bugs hide.

The good news: it follows a few simple rules. Learn them and most effect bugs become easy to spot.

The simple idea: "after rendering, also do this"

React's main job is turning state into screen. Some work isn't about the screen at all: starting a timer, subscribing to a WebSocket, fetching data, setting the page title. That's a side effect, and useEffect is where it goes.

import { useEffect } from "react";
 
function Page({ title }) {
  useEffect(() => {
    document.title = title;
  }, [title]);
 
  return <h1>{title}</h1>;
}

Read it as: "after you render, set the document title, and do it again whenever title changes."

The dependency array decides when it runs

You writeIt runs
useEffect(fn)After every render
useEffect(fn, [])Once, after the first render
useEffect(fn, [a, b])After the first render, then whenever a or b changes

Think of a tennis line judge. You tell them which lines to watch. An empty list means "watch nothing after the first serve". No list at all means "shout after every single shot", which is exhausting. The dependency array is the list of lines.

React compares each dependency with its previous value using Object.is. For numbers and strings that means "same value". For objects, arrays and functions it means "the same object in memory", which is the root of most loops, as we'll see.

The rule: list everything the effect reads

If the effect uses a prop, state or a function defined in the component, it belongs in the array. Leave one out and the effect keeps using an old value, a stale closure.

Install eslint-plugin-react-hooks and keep the exhaustive-deps rule on. It does this bookkeeping for you and is almost always right.

Cleanup: tidy up after yourself

Many effects start something that keeps running: a timer, a listener, a connection. If you don't stop it, it keeps running after the component is gone, or you end up with ten copies of it.

Return a function from the effect, and React calls it to clean up:

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

React runs the cleanup:

  1. before running the effect again (because a dependency changed), and
  2. when the component unmounts.

Every "start" needs a matching "stop":

Effect startsCleanup stops
setInterval / setTimeoutclearInterval / clearTimeout
addEventListenerremoveEventListener
WebSocket or subscription.close() / unsubscribe
fetchcontroller.abort() (see fetching data safely)

Why it runs twice in development

In development, React's StrictMode mounts each component, immediately unmounts it, and mounts it again. That runs effect โ†’ cleanup โ†’ effect. It's a deliberate test: if your app breaks or you see duplicate listeners, your cleanup is missing. Production runs once.

The four infinite loops

Loop 1: setting state that's in the dependencies

const [count, setCount] = useState(0);
 
useEffect(() => {
  setCount(count + 1);
}, [count]); // ๐Ÿ” count changes โ†’ effect runs โ†’ count changes โ†’ ...

The effect changes count, which triggers the effect, which changes count. Ask whether you need an effect at all (usually you don't, see below). If you only need to update from the previous value once, use a functional update and remove the dependency:

useEffect(() => {
  setCount((c) => c + 1);
}, []);

Loop 2: no dependency array plus setState

useEffect(() => {
  setData(transform(raw)); // ๐Ÿ” runs after every render, and causes one
});

No array means "after every render", and setData causes a render. Add the array, or better, calculate transform(raw) during render instead.

Loop 3: an object or array in the dependencies

function Results({ query }) {
  const options = { query, limit: 10 }; // a new object every render
 
  useEffect(() => {
    fetchResults(options).then(setResults);
  }, [options]); // ๐Ÿ” always "different"
}

options is rebuilt on every render, so React sees a new object every time, and the effect sets state, which renders, which builds a new object. Fix it by depending on the primitive values, and building the object inside the effect:

useEffect(() => {
  fetchResults({ query, limit: 10 }).then(setResults);
}, [query]);

If the object must live outside, wrap it in useMemo.

Loop 4: a function in the dependencies

Same problem, same family: a function defined in the component body is new on every render.

const load = () => fetchResults(query).then(setResults);
useEffect(() => { load(); }, [load]); // ๐Ÿ”

Move the function inside the effect, or wrap it in useCallback with its own dependencies.

The effects you can delete

Many effects shouldn't exist. If something can be calculated from props or state, calculate it during render:

// โŒ extra state + effect: renders twice, briefly shows the old total
const [total, setTotal] = useState(0);
useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
 
// โœ… just calculate it
const total = items.reduce((sum, i) => sum + i.price, 0);

Other effects that usually belong elsewhere:

  • Reacting to a click. Put the code in the click handler, not in an effect that watches state the click changed.
  • Resetting state when a prop changes. Give the component a key instead: <Profile key={userId} /> gives each user a fresh component.
  • Expensive calculations. Use useMemo, not an effect plus state.

A good test: effects are for syncing with things outside React, like the DOM, timers, the network and browser APIs. If no outside system is involved, you probably don't need one.

What goes wrong

  • Missing dependencies: stale values. Trust the lint rule.
  • Missing cleanup: duplicate listeners, leaks, requests that update unmounted components.
  • Objects and functions as dependencies: infinite loops. Depend on primitives.
  • Effects that compute data: extra renders and flicker. Calculate during render.

In one line: useEffect syncs React with the outside world after rendering. List every value it reads, clean up whatever it starts, depend on primitives to avoid loops, and don't use it for values you can simply calculate.

Keep reading

New games, tools and tutorials

About one short email a week. No spam, one-click unsubscribe.