React useEffect Infinite Loop — Object Dependency Classic

React hooks JavaScript

Chrome’s Performance tab shows your component rendering 400 times a second. The Network tab is a vertical stripe of identical GETs. Someone “fixed” a stale closure by adding options to the useEffect dependency array. options is { pageSize: 20 } created inline in the parent. Every render builds a new object. React’s Object.is check fails. Effect runs. Effect sets state. Re-render. Loop.

This is the classic trap. The eslint rule react-hooks/exhaustive-deps is not wrong — your dependency identity is.

The minimal reproduction

function Parent() {
  return <UserList options={{ pageSize: 20 }} />;
}

function UserList({ options }: { options: { pageSize: number } }) {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/users?limit=${options.pageSize}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) setUsers(data);
      });
    return () => {
      cancelled = true;
    };
  }, [options]); // new object every Parent render

  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Even if Parent itself is stable, any sibling state update re-renders Parent, recreates options, and re-fires the effect. Strict Mode double-mounting in development makes it look twice as bad — but production will still thrash if something upstream re-renders often.

Fix by stabilizing identity

Primitive deps when you can. Depend on options.pageSize, not the bag.

useEffect(() => {
  // ...
}, [options.pageSize]);

Memoize at the call site when the object is real config:

const options = useMemo(() => ({ pageSize: 20, sort: "name" }), []);
return <UserList options={options} />;

Lift constants outside the component if they never change:

const DEFAULT_OPTIONS = { pageSize: 20 };
function Parent() {
  return <UserList options={DEFAULT_OPTIONS} />;
}

Do not silence the lint rule with an empty array unless you truly want mount-only behavior and you understand stale props. Empty deps plus reading options inside is a different class of bug (stale data), not a fix for the loop.

Other identities that break equality

DependencyWhy it churnsStabilizer
Inline object / arrayNew reference each renderuseMemo, hoist, or primitives
Inline functionNew function each renderuseCallback or move handler down
Context value objectProvider recreates valueMemoize provider value
location from some routersNew object on each navigation tickDepend on pathname / search strings
// Bad: new function identity
useEffect(() => { onReady(); }, [onReady]);

// Better: parent wraps with useCallback, or effect depends on a stable id

If you need the latest callback without re-subscribing, keep a ref:

const onReadyRef = useRef(onReady);
useEffect(() => {
  onReadyRef.current = onReady;
});
useEffect(() => {
  const id = setInterval(() => onReadyRef.current(), 1000);
  return () => clearInterval(id);
}, []); // subscribe once

How to confirm it is this bug

  1. Add console.count("effect") inside the effect. If it climbs without user input, you have a loop.
  2. Log the dependency with a useRef previous-value compare — print which key changed.
  3. Temporarily replace the object dep with a JSON.stringify of its fields (debug only) — if the loop stops, identity was the issue.
  4. Check React DevTools “Highlight updates” — the parent flashing every frame usually owns the unstable prop.

setState patterns that amplify the fire

Setting state unconditionally inside an effect that depends on that same state (or on a derived object from it) is the other common fuse:

useEffect(() => {
  setFilters({ ...filters, ready: true }); // filters in deps → loop
}, [filters]);

Prefer functional updates and narrower deps: setFilters((f) => ({ ...f, ready: true })) with [] or a boolean flag that flips once.

Stabilize what you put in the array. Measure with console.count before you reach for suppress comments. The infinite loop is almost always a new object wearing a familiar shape.