A React counter's click handler is created as useCallback(() => setCount(count + 1), []). The number goes to 1 and then stops changing. What is happening, and how do you fix it?
answer
- one set of variables per render
- the closure kept its render's value
- empty deps means never rebuilt
- state equals itself, React bails out
- updater form reads the live value
basics
~20 sThe empty dependency array pins the handler to the first render, where count was 0, so every click computes 0 + 1. Fix it with the updater form setCount(c => c + 1), or by listing count as a dependency.
solid answer
~50 sEach render of a component has its own `count` variable, and the closure you create captures the one from the render it was made in. With an empty dependency array, `useCallback` keeps returning the function built on the first render forever, and that function's `count` is permanently `0`. Every click therefore computes `0 + 1` and sets the state to 1, so after the first click nothing appears to change. There are two clean fixes. The updater form, `setCount(c => c + 1)`, takes the current value from React instead of from the closure, so the handler no longer needs to be reactive and the empty dependency array becomes honest. Alternatively list `count` in the dependencies, which recreates the handler whenever the value changes — correct, but it gives up the stable identity you presumably wanted. The `react-hooks/exhaustive-deps` lint rule flags the omission.
code
javascript · 21 linesimport { useCallback, useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// frozen on render 0: always computes 0 + 1
const stale = useCallback(() => setCount(count + 1), []);
// no captured state, so the empty dep array is honest
const fresh = useCallback(() => setCount(c => c + 1), []);
return (
<>
<span>{count}</span>
<button onClick={stale}>stale</button>
<button onClick={fresh}>fresh</button>
</>
);
}
export default Counter;go deeper
Recall that state values are fixed for the render that read them, and that setCount(c => c + 1) is the safe way to increment when the next value depends on the previous one.
Explain the mechanism: an empty dependency array returns the first render's function forever, that closure holds the first render's count, and the repeated identical update makes React bail out of re-rendering.
Weigh the two fixes out loud — the updater form keeps the identity stable, honest dependencies keep it correct but recreate the function — and say when the memoization should simply be deleted.
Own the prevention story: exhaustive-deps enforced rather than suppressed, a position on the React Compiler removing hand-written dependency arrays, and review habits that treat an empty dependency array over live state as a defect.
## Each render has its own variables When React renders a function component it calls the function, and that call creates a fresh set of local bindings. `const [count, setCount] = useState(0)` does not give you a live view into a mutable box; it gives *this render* a `count` that holds the value for this render and never changes afterwards. The render where the count was 0 has a `count` of 0 forever, even after the state has moved on. Any function you create during that render closes over that render's bindings. Normally this is invisible, because a new render creates a new handler over the new bindings and React uses the newest one. ## What the empty dependency array pins `useCallback(fn, deps)` returns the function object it stored earlier as long as the dependencies still compare equal. With `[]`, nothing ever compares unequal, so React returns the function created during the first render on every subsequent render. That function is a closure over render 0's variables. Its `count` is 0. The component re-renders, the display shows the new value, but the handler React dispatches to is still the one from render 0. ```jsx const onClick = useCallback(() => setCount(count + 1), []); // captures count === 0 ``` ## Why it looks like "stuck at 1" The first click computes `0 + 1` and sets the state to 1 — a visible change, which is what makes the bug confusing. The second click runs the same frozen function, computes `0 + 1` again, and asks for 1. React compares the requested state with the current state, finds them equal, and bails out of re-rendering. So the counter increments once and then appears completely dead. The same shape shows up with a handler that pushes onto an array captured from the first render, or one that reads a prop the parent has since changed. ## Fix 1: stop reading state in the handler The root problem is that the handler reads state it does not need to read. The updater form asks React for the current value at the moment the update is processed: ```jsx const onClick = useCallback(() => setCount(c => c + 1), []); ``` Now the closure captures nothing that goes stale, so the empty dependency array is truthful and the handler keeps a stable identity across renders — the thing you wanted `useCallback` for in the first place. This is the preferred fix whenever the new state is a pure function of the old state. ## Fix 2: tell the truth in the dependencies If the handler genuinely needs the current value for something other than deriving the next state — sending it in a request, comparing it against a prop — then it *is* reactive and must be recreated when the value changes: ```jsx const onClick = useCallback(() => report(count), [count]); ``` This is correct, but be honest about what you bought: the function's identity now changes whenever `count` changes, so any memoized child receiving it re-renders then too. If `count` changes on every keystroke, `useCallback` has become a no-op with extra ceremony. ## Why the inline version does not have this bug ```jsx <button onClick={() => setCount(count + 1)}>{count}</button> ``` This creates a new closure on every render over that render's `count`, so it is always current. The bug did not come from closures; it came from *keeping* one closure alive past the render it belonged to. Freezing identity and reading changing values are in direct tension, and every stale-closure bug is some version of that trade being made carelessly. ## Guardrails The `react-hooks/exhaustive-deps` lint rule reports the omitted dependency and is the main mechanical defence — silencing it with a disable comment is how these bugs get shipped. In React 19 codebases that enable the React Compiler, the compiler derives memoization itself for code that follows the Rules of React, which removes the hand-written dependency array and this whole failure mode with it, but only for code that plays by those rules. When debugging, the fastest confirmation is to log the captured value inside the handler and compare it with what the component renders. If the displayed value moves and the logged value does not, you are holding a function from an older render.
- Why does the counter stop at 1 rather than flickering between values?Because every click asks for the same value. The frozen handler computes 0 + 1, so React is told to set the state to 1 when it is already 1; it compares the two, finds them equal and skips the re-render. The first click looked like it worked, which is what makes the bug hard to spot.
- When is listing count in the dependencies the right fix rather than the updater form?When the handler needs the current value for something other than deriving the next state — sending it to an API, branching on it, comparing it with a prop. Then the handler really is reactive and should be rebuilt when the value changes. Accept that its identity now changes too, and check whether the useCallback is still earning anything.
- Would removing useCallback entirely fix the bug?Yes. Without it, a new closure is created on every render over that render's variables, so the handler always sees current values. That is worth saying plainly: the bug is not caused by closures, it is caused by preserving one past its render. Remove the memoization unless something downstream actually compares the handler's identity.
saying these in an interview costs you the question
- Says state updates are asynchronous, so the value is not ready yet
- Thinks count is a mutable variable the closure reads live
- Adds a ref to "fix" it without understanding the capture
- Silences the exhaustive-deps warning to keep the empty array
- Believes an inline arrow handler would have the same stale value