skip to content

How does React decide whether to re-run a useEffect between renders, and why does a dependency like useEffect(fn, [{ page: 1 }]) make the effect run after every single render?

level: middleimportance: must knowfreq 63%

answer

  1. identity, never structure
  2. Object.is, index by index
  3. fresh literal, fresh reference
  4. effect plus setState equals loop
  5. narrow to primitives first

basics

~20 s

React compares each dependency with the previous render's value using Object.is, position by position. An object, array or function created in the component body is a new reference on every render, so Object.is always reports a change and the effect re-runs each time.

solid answer

~50 s

After each render React takes the dependency array and compares it entry by entry against the array from the previous run, using `Object.is` — the same identity-based comparison it uses for state bail-outs. There is no deep or structural comparison. An object literal, array literal, or function expression written in the component body is constructed fresh on every render, so even though `{ page: 1 }` looks identical to last render's `{ page: 1 }`, `Object.is` says they differ and the effect re-synchronizes. If that effect also sets state, you get an infinite render loop. The fixes are, in order of preference: depend on the primitive fields the effect actually reads (`[page]`); move the object's construction inside the effect body so it is not a dependency at all; or, when the value must be shared with other code, stabilize its identity with `useMemo` or `useCallback` at the point where it is created.

code

javascript · 21 lines
javascript
import { useEffect, useMemo, useState } from 'react';

export function useSubscription(page) {
  const [items, setItems] = useState([]);

  // Unstable: a new object every render, so the effect re-runs every render.
  // const query = { page, sort: 'asc' };
  // useEffect(() => { setItems(load(query)); }, [query]);

  // Stable: identity changes only when page changes.
  const query = useMemo(() => ({ page, sort: 'asc' }), [page]);
  useEffect(() => {
    setItems(load(query));
  }, [query]);

  return items;
}

function load(query) {
  return [query.page];
}

go deeper

for a junior

Remember that objects, arrays and functions written inside a component are brand new on every render, so listing them as dependencies makes the effect run every time. Prefer depending on plain strings and numbers.

for a middle

State the rule precisely: React applies Object.is to each dependency by position, with no structural comparison, and explain how that turns an inline literal into a permanent 'changed' signal — and into a loop when the effect sets state.

for a senior

Diagnose from the symptom to the unstable identity, and choose deliberately between narrowing to primitives, constructing the value inside the effect, and memoizing at the source. Be explicit that deleting the dependency converts a visible loop into a silent staleness bug.

for a principal

Set the direction: unstable identities should be fixed where values are created, not patched at each consumer, and scattering useMemo defensively is its own maintenance cost. Decide how the codebase handles this systematically and what it means for shared hooks and provider values.

## The comparison React actually performs On every render where the effect exists, React holds two arrays: the dependencies from the previous run and the ones just produced. It walks them by index and applies `Object.is` to each pair. If every pair matches, React skips the effect entirely. If any pair differs, React runs the previous run's cleanup and then the effect body again. Two properties of that algorithm explain almost every dependency-array surprise: - **It is identity-based.** `Object.is` returns true for the same primitive value and for the *same* object reference. It does not look inside objects. `Object.is({ page: 1 }, { page: 1 })` is `false`. - **It is positional.** Entry 0 is compared with entry 0. The array must therefore keep a constant length and order across renders; React warns in development when the size changes. ## Why a fresh literal is always "changed" A React component function runs from top to bottom on every render. Any expression in its body that constructs a value produces a *new* value each time: ```js function Results({ page }) { const query = { page, sort: 'asc' }; // new object every render const onDone = () => console.log('done'); // new function every render useEffect(() => { subscribe(query, onDone); }, [query, onDone]); // never equal to last render's entries } ``` Nothing is wrong with the dependency array here — it honestly lists what the effect reads. The problem is upstream: the values it lists have unstable identities. React cannot tell the difference between "a genuinely new query object" and "the same query rebuilt", because at the language level there is no difference. ## The two failure shapes The mild shape is wasted work: the effect re-subscribes, re-measures or re-registers on every render, including renders that changed nothing relevant. On a hot component that is real cost, and it can also produce visible artifacts — a re-created subscription drops and re-establishes, an analytics event fires repeatedly. The severe shape is a loop. If the effect ends by setting state, that state change renders the component, which rebuilds the literal, which fails the comparison, which runs the effect, which sets state again. The component spins until React or the browser gives up. When someone reports "my effect runs infinitely", an unstable object or function dependency is the first suspect. ## Fix 1: depend on primitives The best fix is usually to narrow the dependency to the values the effect genuinely reacts to. Strings, numbers and booleans compare by value under `Object.is`, so they are stable across renders when their content is stable: ```js useEffect(() => { subscribe({ page, sort: 'asc' }); }, [page]); ``` The object is now built inside the effect, which means it is not a dependency at all. This is both the smallest change and the most readable one: the array now says exactly what the effect is synchronized with. ## Fix 2: stabilize the identity at its source When the value must exist outside the effect — it is also passed to a child, or it is a prop the parent supplies — the identity has to be made stable where it is created. `useMemo` for values, `useCallback` for functions: ```js const query = useMemo(() => ({ page, sort: 'asc' }), [page]); ``` Now `query` keeps the same reference until `page` changes, and the effect's dependency comparison behaves the way the reader intuitively expects. Note that this only moves the problem if the memo's own dependencies are unstable — memoizing an object whose input is another fresh object achieves nothing. ## The fix that is not a fix Deleting the entry from the array stops the re-runs, and it is the wrong move. The effect still reads the value; you have only stopped telling React about it. What was a visible loop becomes an invisible staleness bug: the effect keeps operating on the object from whichever render last triggered it. Trading a loud failure for a quiet one is a bad trade, and interviewers ask this question specifically to see whether a candidate reaches for it. ## Where the comparison shows up elsewhere The same `Object.is` rule governs the dependency arrays of `useMemo` and `useCallback`, so the reasoning transfers directly. Recognizing one comparison rule behind all three hooks — and knowing that it is identity, never structure — is what makes dependency arrays feel predictable rather than mysterious.

  • An effect with an unstable object dependency also calls a state setter. What symptom does the user see?
    An apparent freeze or a runaway component: each render rebuilds the object, the comparison fails, the effect runs, it sets state, and that triggers the next render. React eventually reports too many re-renders, or the tab pins the CPU. The tell is that the loop stops the moment you replace the object dependency with the primitives it wraps.
  • Why does memoizing the object with useMemo sometimes fail to stop the re-runs?
    Because `useMemo` uses the same `Object.is` comparison on *its* dependency array. If you memoize `{ page, filters }` but `filters` is itself an object rebuilt every render, the memo invalidates every render and returns a new object, so nothing is stabilized. The instability has to be fixed at the point where the innermost unstable value is created.
  • Does the same comparison rule apply to arrays of primitives passed as a dependency?
    Yes, and it catches people out. `[ids]` where `ids` is an array literal built in the component body compares the array reference, not its elements, so it differs every render even when the contents match. Either depend on a derived primitive such as `ids.join(',')` — cheap for small lists — or stabilize the array itself with `useMemo`.

saying these in an interview costs you the question

  • Assuming React deep-compares dependency objects
  • Thinking two structurally identical literals are equal
  • Removing the dependency instead of stabilizing it
  • Believing useMemo fixes identity regardless of its own deps
  • Treating repeated effect runs as a React bug rather than an identity problem

context