skip to content

In a React component, an effect re-runs on every render because it depends on a callback prop that the parent recreates each time. A teammate stops the churn by adding // eslint-disable-next-line react-hooks/exhaustive-deps and dropping the callback from the dependency array. Why is that the wrong fix, and what would you do instead?

level: seniorimportance: should knowfreq 52%

answer

  1. the warning is a verifier, not a preference
  2. the dependency does not disappear
  3. loud bug becomes quiet bug
  4. fix the identity where it is created
  5. scoped disable with a written reason

basics

~20 s

Suppressing react-hooks/exhaustive-deps does not remove the dependency, only the warning. The effect keeps calling whichever version of the callback the last run captured, so a loud churn problem becomes a silent staleness bug. Fix the identity at the source instead.

solid answer

~50 s

The rule is not a style preference; it mechanically lists the reactive values the effect body reads, and dropping one is a claim that the effect does not depend on it. That claim is false here, so the effect keeps invoking the callback instance it captured on its last run — if the parent later closes over new state in that callback, the effect silently calls a stale version, and nothing warns anyone. The real defect is upstream: the parent recreates the function every render. Fix it there with `useCallback` (or by moving the function out of the component if it closes over nothing), or move the logic inside the effect so it is not a dependency at all. If the effect only needs the callback to report an outcome rather than to re-synchronize on it, keeping the latest value in a ref is an explicit alternative — but it should be a deliberate, commented decision, not an eslint-disable comment.

go deeper

for a junior

Know that the exhaustive-deps warning is pointing at a real value your effect reads, and that turning it off does not stop the effect from reading it. Raise it in review rather than silencing it.

for a middle

Explain the mechanism: after suppression the effect keeps the function instance it captured on its last run, so later versions closing over newer state are never called. Name the standard fixes — useCallback at the source, defining the function inside the effect, a functional state updater.

for a senior

Lead with diagnosis rather than the rule. Trace the unstable identity to where the value is created, weigh re-synchronizing against holding the latest callback in a ref, and articulate that suppression trades a reproducible churn for a silent staleness bug.

for a principal

Own the policy. Decide whether suppressions are allowed at all, require a scoped comment with a stated reason when they are, and address the systemic cause — unmemoized provider values and hook return values — so individual components are not each inventing an escape hatch.

## What the rule is actually asserting `react-hooks/exhaustive-deps` performs a static read of the effect body and collects every reactive value it references — props, state, and anything computed from them inside the component. Its claim is narrow and checkable: *this list is what the effect reads, so this list is what must be in the array*. It is not guessing at intent and it is not a performance heuristic. When it warns, one of two things is true: the array is wrong, or the code is shaped in a way that makes the honest array unusable. Suppressing the warning resolves neither. It only removes React's ability to re-synchronize the effect when that value changes. ## Why the suppression makes things worse, not merely equal Before the suppression the failure was visible: the effect ran on every render. Wasteful, maybe noisy, but discoverable in a profiler and reproducible on demand. After the suppression the effect runs once and then holds the callback instance from that run. Suppose the parent's callback closes over a `selectedId` that changes later. The effect happily invokes the old function, which writes to the old id. Nothing throws, nothing warns, and the bug surfaces as an inconsistency reported by a user days later — "it saved to the wrong record". That is a strictly worse failure mode: same defect, no signal. The general principle worth saying out loud: a dependency array is a declaration, and the lint rule verifies the declaration against the code. Silencing the verifier does not change what the code depends on. ## Find the real cause first Ask which dependency is unstable and why. Almost always the answer is that something is being recreated every render: - a function expression or arrow defined in the parent's body and passed down, - an object or array literal built inline in JSX, - a value returned unmemoized from a custom hook, - a context value object constructed fresh in a provider. Once you name the source, the fix is usually at the source and it is small. ## The fixes, in the order I would try them **Move the work inside the effect.** If the function exists only to serve the effect, define it inside the effect body. It stops being a dependency, and the array shrinks to the values it genuinely reads. ```js useEffect(() => { function report(result) { /* ... */ } const conn = connect(roomId, report); return () => conn.close(); }, [roomId]); ``` **Stabilize the identity where the value is created.** If the callback must be a prop, the parent wraps it in `useCallback` with its own honest dependency list. Then the child's effect can list it truthfully and it will not churn. **Use a functional state updater.** When the only reason a dependency exists is that the effect computes new state from current state, `setX(prev => ...)` removes the read entirely and the dependency disappears legitimately. **Keep the latest value in a ref, deliberately.** When an effect must maintain a long-lived resource — a socket, an interval, an observer — and merely needs to call the newest callback when something happens, writing the callback into a ref and calling `ref.current(...)` is a recognized pattern. It keeps the resource alive across changes, at the cost of making the effect explicitly non-reactive to that value. Do this in the open, with a comment explaining why the effect must not re-synchronize, rather than by hiding the dependency. ## Is suppression ever defensible? Sparingly, and it should look nothing like the example above. A genuine mount-only imperative bootstrap — logging an app-open event, initializing a third-party widget exactly once — sometimes cannot be expressed through the dependency array. When you accept a suppression, three things make it survivable: it is `eslint-disable-next-line` scoped to that one array (never a file-level disable), it carries a comment explaining what is intentionally not reactive, and the values it hides are ones you can argue will never meaningfully change. "I could not get rid of the loop" is not that argument. ## How to say this in an interview Lead with the diagnosis rather than the rule: the effect re-runs because a prop's identity is unstable, and the identity should be fixed where the value is created. Then explain what the suppression trades away — a visible, reproducible churn replaced by an invisible staleness bug — and name the concrete fixes. Finishing with the narrow, commented case where suppression is acceptable shows judgment rather than dogma, which is exactly what the question is probing at senior level.

  • How would you demonstrate that the suppressed version is broken, given it looks fine in the browser?
    Make the captured value change and observe the effect acting on the old one: have the parent's callback close over a piece of state, change that state, and log the value the effect passes through. With the dependency restored the effect re-synchronizes and logs the new value; with it suppressed it keeps logging the old one. That reproduction is what turns a review argument into a demonstrated defect.
  • The parent wraps the callback in useCallback and the child's effect still re-runs every render. What now?
    The memo's own dependency array is unstable. `useCallback` compares its dependencies with `Object.is` just as effects do, so if it lists an object or array rebuilt each render, it returns a new function each render. Trace back to the innermost unstable value and stabilize that; memoizing a consumer of an unstable input never helps.
  • When is the latest-callback-in-a-ref pattern the right answer rather than an escape hatch?
    When the effect owns a resource whose recreation is genuinely expensive or user-visible — a WebSocket, an interval whose phase matters, an IntersectionObserver — and the callback is only invoked on events, not used to decide whether to re-synchronize. Write it explicitly, with a comment saying the effect is intentionally not reactive to that value, so the next reader is not misled.
  • What would you say if a teammate argues the React Compiler removes the need to worry about this?
    Build-time auto-memoization can stabilize identities that the compiler can prove are safe to stabilize, which removes a class of churn. It does not license lying in a dependency array: its correctness depends on the code following the Rules of React, and a suppressed dependency is still a value the effect reads without telling React about it. Fix the declaration; treat the compiler as an optimization, not a licence.

saying these in an interview costs you the question

  • Calling exhaustive-deps a style rule you can safely ignore
  • Believing removing a dependency removes the dependency
  • Adding a file-level disable rather than a scoped one
  • Claiming the effect will still see current values afterwards
  • Reaching for a ref before checking why the identity is unstable

context