Back to React
React
medium
mid

How does React handle circular dependencies in useEffect?

It doesn't — circular useEffect dependencies cause infinite re-renders. Typical bug: effect writes to state that's in its own dep array, triggering itself. Fixes: (1) use functional updaters `setX(prev => ...)` to read latest state without depending on it, (2) move the derived value into useMemo computed in render, (3) use refs for values that should not trigger re-runs, (4) reset state via `key` prop instead of effect-driven sync.

7 min read·~5 min to think through

'Circular dependencies in useEffect' is shorthand for: an effect mutates state that's in its own deps, so it re-fires forever.

The bug

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

  useEffect(() => {
    setCount(count + 1);
  }, [count]); // every change re-runs the effect
  // → infinite render loop, browser hangs
}

Why React doesn't 'handle' it

React's effect model is: 'run me when these deps change'. If the effect's body changes one of those deps, the model demands another run. There's no built-in cycle detection — that would require static analysis React doesn't do.

Common shapes of the bug

1. Set state from state.

tsx
useEffect(() => {
  setUsers(users.filter(u => u.active));
}, [users]); // setUsers changes users → re-run

Fix: compute in render, not in effect.

tsx
const activeUsers = useMemo(() => users.filter(u => u.active), [users]);

2. Setting derived state.

tsx
useEffect(() => {
  setFullName(`${first} ${last}`);
}, [first, last]);

Fix: don't store derived values in state.

tsx
const fullName = `${first} ${last}`;

3. Object/array dep that's recreated every render.

tsx
function Parent() {
  const filters = { status: 'active' }; // new object each render
  return <Child filters={filters} />;
}

function Child({ filters }: { filters: Filters }) {
  useEffect(() => {
    fetchData(filters);
  }, [filters]); // filters is new every render → effect runs every render
}

Fix: memoize the object at the source.

tsx
const filters = useMemo(() => ({ status: 'active' }), []);

4. Function as a dep (without useCallback).

tsx
function Parent() {
  const onLoad = () => console.log('loaded'); // new each render
  return <Child onLoad={onLoad} />;
}

function Child({ onLoad }: { onLoad: () => void }) {
  useEffect(() => { onLoad(); }, [onLoad]); // every render
}

Fix: useCallback in the parent.

5. Functional updater unused.

tsx
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000); // stale closure
  return () => clearInterval(id);
}, [count]); // re-creates interval every count

Fix: functional updater, no count dep.

tsx
useEffect(() => {
  const id = setInterval(() => setCount(c => c + 1), 1000);
  return () => clearInterval(id);
}, []);

When you genuinely need to set state in an effect

Sometimes you do — e.g. syncing to/from an external store. Read the latest state via functional updater or a ref:

tsx
const stateRef = useRef(state);
useEffect(() => { stateRef.current = state; }, [state]);

useEffect(() => {
  // can read stateRef.current without re-firing
  const handler = (e: Event) => doStuff(stateRef.current, e);
  window.addEventListener('x', handler);
  return () => window.removeEventListener('x', handler);
}, []); // no state dep

Resetting state on prop change — don't use effect

A classic infinite-loop trap is using an effect to 'reset' state when a prop changes:

tsx
useEffect(() => { setLocal(prop); }, [prop]); // bug-prone

Fix: change the key.

tsx
<Form key={userId} initialValues={user} />

When key changes, React remounts the subtree — state resets cleanly.

Detection

The lint rule react-hooks/exhaustive-deps flags missing deps. Suppressing it without thinking is the proximate cause of most cycles.

Senior framing

'React doesn't handle circular deps' is technically true and practically not the point. The senior signal is recognizing the shape of the bug — state in an effect's deps gets set by the effect itself — and reaching for the right fix (functional updater, useMemo, key reset) instead of the wrong one (suppress the lint warning).

Follow-up questions

  • When is it correct to set state inside useEffect?
  • Why does useCallback help break dep cycles?
  • How does using a key to reset state avoid useEffect cycles?

Common mistakes

  • Suppressing the exhaustive-deps lint instead of fixing the cycle.
  • Computing derived state in useEffect instead of in render.
  • Using non-memoized objects/functions as deps.

Performance considerations

  • An infinite cycle freezes the browser. Even a near-infinite (e.g. 100 renders before stabilizing) is a real perf bug. The dev experience: ESLint complains, then the browser hangs — both clear signals.

Edge cases

  • Async work inside an effect makes cycles harder to spot.
  • If the effect's body conditionally sets state, the cycle may only fire under specific conditions.
  • Two effects mutually triggering each other look identical to a self-cycle.

Real-world examples

  • Common in forms (effect syncs form state to props), in router-driven UIs (effect reads URL and writes state), and in poorly designed custom hooks (hook fetches based on its own returned state).

Senior engineer discussion

Senior framing: cycles are almost always 'I tried to use useEffect to sync state to state'. The React team explicitly wrote 'You might not need an effect' for this category. The fix is usually NOT in the effect — it's deleting the effect and computing in render or using a key.

Related questions