skip to content

In React, why is the dispatch function returned by useReducer safe to leave out of a useEffect dependency array, and what does that stability let you avoid elsewhere?

level: juniorimportance: should knowfreq 45%

answer

  1. identity never changes across renders
  2. dependency arrays detect change
  3. the same holds for a useState setter
  4. no useCallback needed for memo children
  5. stable is not the same as fresh

basics

~20 s

React guarantees that the dispatch function keeps the same identity for the whole lifetime of the component, so listing it in a dependency array can never cause the effect to re-run. That stability also means dispatch needs no useCallback when passed to memoized children.

solid answer

~50 s

React returns the *same* `dispatch` function on every render of that component instance — its identity never changes for the component's lifetime. Since dependency arrays exist to detect changed values, a value that can never change is a no-op there: including it is harmless, omitting it is safe, and the `react-hooks/exhaustive-deps` lint rule knows this and does not flag it. The practical payoff is bigger than the dependency array. Because `dispatch` is referentially stable, passing it down to a child wrapped in `React.memo` never breaks that memoization, so you avoid the `useCallback` wrapping that inline handlers require. That is one of the real arguments for handing children a `dispatch` instead of a bundle of callbacks. The one thing stability does *not* give you is fresh state: `dispatch` being stable says nothing about values your effect closes over, which still need their own dependencies.

go deeper

for a junior

Know the plain fact: React returns the same dispatch function every render, so it never needs to be in a dependency array and never needs useCallback.

for a middle

Explain why a dependency array cannot react to a value that never changes, and contrast dispatch with a handler defined in the component body, which is a new object each render.

for a senior

Use the guarantee deliberately — a stable dispatch threaded down instead of many memoized callbacks, or split into its own context — and be ready to debug a stale-closure interval by moving the computation into the reducer.

for a principal

Treat it as an API-design lever: exposing a stable dispatch (or a stable action object) from a custom hook or provider is what keeps consumers free of memoization ceremony, and it is a contract worth stating explicitly.

## The guarantee `const [state, dispatch] = useReducer(reducer, initialState)` returns a `dispatch` whose identity React keeps constant across every render of that component instance. It is created once, when the hook is first mounted, and returned unchanged forever after. The state setter from `useState` carries the same guarantee. That single fact explains everything else here. ## Why the dependency array does not care A dependency array is a change detector: React compares each entry with its value from the previous render and re-runs the effect if any differ. A value that is identical on every render can never trigger that comparison to fail. So: ```js useEffect(() => { const id = setInterval(() => dispatch({ type: 'ticked' }), 1000); return () => clearInterval(id); }, []); // safe: dispatch cannot change // [dispatch] // equally correct, and equally never re-runs ``` Both forms behave identically. The `react-hooks/exhaustive-deps` lint rule has this knowledge built in: it will not complain about an omitted `dispatch`, and many codebases still list it for explicitness. Neither choice is a bug. Compare that with a handler you define in the component body, which is a *new* function object every render — omitting that from the array creates a stale closure, and including it re-runs the effect on every render unless you wrap it. ## What the stability actually buys you **No `useCallback` when passing it down.** An inline `onChange={() => setX(1)}` is a fresh function each render, so a `React.memo`-wrapped child re-renders every time unless the parent wraps the handler in `useCallback`. `dispatch` needs none of that: ```js <ExpensiveList dispatch={dispatch} /> // memo holds; no useCallback needed ``` This is one of the genuine architectural arguments for a reducer: instead of threading five memoized callbacks through the tree, you thread one stable `dispatch`, and children describe what happened. **A stable value to put in context.** Because it never changes, `dispatch` can be placed in a context that is separate from the state context, and consumers that only dispatch will not re-render when the state changes. **Interval and subscription callbacks stay simple.** Long-lived callbacks — an interval, a socket handler, an event listener registered once — can call `dispatch` without the effect needing to be torn down and re-created to keep the reference current. ## The trap: stable is not fresh Stability applies to the function, not to anything around it. Two things people wrongly infer: 1. **"Since dispatch is stable, my effect can use an empty dependency array."** No. Any *other* value the effect reads — props, state, a derived object — still needs to be in the array or accounted for, or the effect closes over a stale render's values. `dispatch` being exempt exempts only itself. 2. **"Since dispatch is stable, the action I build inside the callback sees current state."** No. If you compute the payload from `state` captured in a long-lived callback, you capture the value from the render in which the callback was created. The correct answer is to let the *reducer* read the latest state — dispatch an action describing the event and compute from `state` inside the reducer, which always receives the current state: ```js // stale: count is from the render that created the interval setInterval(() => dispatch({ type: 'set', value: count + 1 }), 1000); // correct: the reducer reads current state setInterval(() => dispatch({ type: 'ticked' }), 1000); ``` That second form is a big practical reason reducers pair well with timers and subscriptions. ## Summary `dispatch` is one of the few React values you can treat as a constant. Use that: pass it down freely, put it in context, close over it in long-lived callbacks. Just do not let its stability convince you that the *state* around it is fresh too.

  • Is including dispatch in the dependency array wrong?
    No, just redundant. Since its identity never changes, the comparison always passes and the effect never re-runs because of it. Some teams list it for explicitness so a reader does not have to know the guarantee; others omit it because the lint rule permits that. Both are correct — this is a style choice, not a correctness one.
  • An interval built with an empty dependency array dispatches { type: 'set', value: count + 1 } and count stops advancing past one. Why?
    The callback was created during the first render and captured `count` as 0 forever, so every tick dispatches the same value. dispatch being stable does not refresh anything the callback closed over. Dispatch an event-shaped action instead — `{ type: 'ticked' }` — and let the reducer compute from the current state it is handed.
  • Does the useState setter carry the same stability guarantee?
    Yes. React returns the same setter function for the lifetime of the component, so it too can be omitted from dependency arrays and passed to memoized children without useCallback. The guarantee does not extend to handlers you write yourself in the component body — those are new function objects on every render.

saying these in an interview costs you the question

  • dispatch changes each render so wrap it in useCallback
  • Because dispatch is stable, an empty deps array is always fine
  • Omitting dispatch causes stale or dropped actions
  • Only useReducer has a stable function; setState does not
  • dispatch stability means state read inside callbacks is current

context