In a React component, `const query = { term, page };` is built in the component body and used as `useEffect(() => { fetchResults(query).then(setRows); }, [query]);`. The effect fires after every render and never settles. Explain the mechanism and give two fixes.
answer
- the loop starts in the render body
- dependencies compared by identity, not contents
- new object each render fails Object.is
- primitives compare by value
basics
~20 sRebuilding query in the component body creates a new object on every render, and React compares each dependency with Object.is, so the effect re-runs, sets state, renders again, and loops. Depend on the primitive fields instead.
solid answer
~50 sReact does not compare dependency *contents*; after each render it runs `Object.is` on each entry against the previous render's entry. `query` is an object literal evaluated in the body, so it is a new reference every render even when `term` and `page` are unchanged — the comparison fails, the effect runs, `setRows` schedules a render, and that render builds another new `query`. That is the loop. The simplest fix is to depend on the primitives the object is made of, `[term, page]`, and construct the object inside the effect callback where its identity is irrelevant. The alternative is `const query = useMemo(() => ({ term, page }), [term, page])`, which keeps one object identity until a primitive actually changes — useful when other hooks or memoized children need the same object. If the object arrives as a prop, fix it at the parent that creates it.
code
javascript · 24 linesimport { useEffect, useMemo, useState } from 'react';
const fetchResults = (q) => Promise.resolve([q.term, q.page]);
export function Results({ term, page }) {
const [rows, setRows] = useState([]);
// Broken - a new object every render, so Object.is fails and this loops:
// const query = { term, page };
// useEffect(() => { fetchResults(query).then(setRows); }, [query]);
// Fix A - depend on the primitive fields, build the object inside
useEffect(() => {
fetchResults({ term, page }).then(setRows);
}, [term, page]);
// Fix B - one object identity per real change, for hooks that need the object
const query = useMemo(() => ({ term, page }), [term, page]);
useEffect(() => {
console.log('query identity changed', query);
}, [query]);
return <pre>{rows.join(',')}</pre>;
}go deeper
Recall that dependencies are compared by identity and that an object written in the component body is new on every render. Be able to say why the effect keeps firing rather than only that it does.
Trace the cycle out loud: new object, failed Object.is, effect runs, state set, render, new object again. Then offer both fixes — primitives in the dependency list, or useMemo — and say when each is appropriate.
Show you find the real creation site, including when the object crosses a prop boundary, and that you weigh removing the effect or flattening the props over adding another memo. Call out the quieter variant: repeated network calls without a visible loop.
Frame it as an API-shape problem: components that accept object props impose an identity contract on every caller. Decide when your codebase pays that cost, and how compiler-level auto-memoization changes the calculus for hand-written stabilization.
## What the dependency array actually does After every render, React stores the dependency array you passed. On the next render it compares the new array to the stored one **element by element with `Object.is`** — the same identity-based sameness check used for state bail-outs. If every element matches, the effect is skipped. If any element differs, React runs the previous cleanup (if any) and then the effect again. That comparison is positional and shallow. It knows nothing about what is inside an element; an object dependency is compared as a reference, full stop. ## Why this particular code loops forever Trace one cycle: 1. Render 1 evaluates the body: `query` = object **A**. Effect runs with `[A]`. 2. `fetchResults(...).then(setRows)` resolves and sets state → render 2. 3. Render 2 evaluates the body again: `query` = object **B**, identical in content, different identity. 4. React compares `Object.is(A, B)` → `false` → the effect runs again → another `setRows` → render 3 → object **C**… Nothing converges because step 3 can never produce the same reference. Even without `setRows`, any render caused by a parent, a sibling state change, or a keystroke elsewhere would re-fire this effect — a request storm rather than an infinite loop, which is the quieter version of the same defect. ```jsx // every render: new object, failed comparison, effect re-runs const query = { term, page }; useEffect(() => { fetchResults(query).then(setRows); }, [query]); ``` ## Fix 1 — depend on primitives Strings, numbers, booleans, `null` and `undefined` compare by value, so `Object.is('shoes', 'shoes')` is `true` regardless of how many times the literal was evaluated. Move the object construction inside the callback and list the primitives it is built from: ```jsx useEffect(() => { fetchResults({ term, page }).then(setRows); }, [term, page]); ``` The object is now created once per *effect run* rather than once per render, and its identity never participates in a comparison. This is the default answer: it is the smallest change, it needs no extra hook, and the dependency list reads as the real inputs. ## Fix 2 — stabilize the object with useMemo When several hooks or a memoized child need the *same* object, give it one identity per real change: ```jsx const query = useMemo(() => ({ term, page }), [term, page]); useEffect(() => { fetchResults(query).then(setRows); }, [query]); ``` Now `query` is a new reference only when a primitive changes, so the effect fires exactly when the inputs change. The catch is that `useMemo` moves the problem rather than removing it: if one of *its* dependencies is itself an unstable object, the memo recomputes every render and you are back where you started. Memoization chains are only as stable as their least stable link. ## When the object is a prop If `query` arrives from a parent that writes `<Results query={{ term, page }} />`, nothing you do in the child fixes it. The instability is created in the parent's render, so that is where it must be stabilized — hoisted to a module constant if invariant, wrapped in `useMemo` if derived, or flattened into primitive props (`term` and `page`) so the child never receives an object at all. Flattening is usually the most durable answer, because it removes an identity contract instead of maintaining one. ## What the lint rule can and cannot tell you The `react-hooks/exhaustive-deps` rule reports that an object or function declared in the component body makes the effect's dependencies change on every render, and suggests moving it inside the effect or wrapping it in `useMemo`. Treat that message as a defect, not noise. What the rule cannot do is decide whether the effect should exist at all, or whether the right fix is architectural — that judgment is yours. ## The wrong fixes to avoid Deleting the dependency array makes the effect run after *every* render, which is strictly worse. Passing `[]` silences the loop but freezes the effect on the first `term` and `page`, giving stale results. Suppressing the lint rule hides the symptom and leaves the next reader without the signal. Each of these trades a visible bug for an invisible one.
- The object is not built locally — it arrives as a prop from a parent that writes it inline in JSX. Where do you fix it?In the parent, at the creation site. A child cannot recover an identity its parent throws away each render. Hoist the value to module scope if it is invariant, wrap it in `useMemo` if derived, or flatten it into primitive props so the child never receives an object. Flattening is the most durable fix because it removes the identity contract entirely.
- Would wrapping the effect callback in useCallback fix the loop?No. The callback's identity is not what React compares — it compares the dependency array entries, and `query` is still a fresh object each render. Wrapping the callback adds a hook and another dependency list without touching the failing comparison. The instability has to be removed from the dependency itself.
- What if the unstable dependency is a function prop rather than an object?The rule is identical: functions are compared by reference, so a handler recreated in the parent's body re-fires the effect every render. Stabilize it with `useCallback` where it is defined, or restructure so the effect does not depend on it — for example by depending on the primitive inputs the function closes over and calling it from inside the effect.
saying these in an interview costs you the question
- Deleting the dependency array to stop the loop
- Passing an empty array and ignoring the lint warning
- Claiming objects with the same keys compare equal
- Suppressing exhaustive-deps instead of fixing identity
- Wrapping in useMemo whose own dependency is unstable