What this usually means
React closures capture values at the time the closure was created. If a callback (onClick, onSubmit) is defined during one render but called during a later render, it sees the state values from the render when it was defined — not the current values. This is the 'stale closure' problem. It happens most often with setTimeout, setInterval, event handlers passed as props, and callbacks stored in refs or external stores.
The first ten minutes \u2014 establish facts before touching code.
- 1Check if the callback is using state directly vs via a ref or functional update. `setCount(count + 1)` uses a potentially stale count. `setCount(prev => prev + 1)` always uses the latest.
- 2Check if the callback is wrapped in useCallback with an empty or incomplete dependency array.
- 3Check if the value comes from a ref. Refs do not trigger re-renders when updated.
- 4Check if the form is using controlled inputs (value + onChange) not just defaultValue.
- 5Log the state value inside the callback and outside it. If they differ, you have a stale closure.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe callback or event handler — what state variables does it close over?
- searchuseCallback and useMemo dependency arrays — are all dependencies listed?
- searchRefs vs state — is the value in a ref (useRef) instead of state (useState)?
- searchControlled vs uncontrolled inputs — is the input using value or defaultValue?
- searchuseEffect cleanup — are timers or subscriptions properly cleaned up?
- searchState update batching — multiple setState calls in the same event handler are batched
Practical causes, not theory. These are the things you will actually find.
- warningCallback closes over a state value from a previous render because the dependency array is empty or incomplete
- warningUsing `setState(value)` instead of `setState(prev => newValue)` when the new value depends on the old
- warningForm input uses defaultValue instead of value + onChange (uncontrolled vs controlled)
- warningTimer or event listener callback was set once and never updated with new state
- warningState is stored in a ref for performance but never triggers an update when it changes
- warningMultiple state updates in an async callback are batched unexpectedly in React 18
Concrete fix directions. Pick the one that matches your root cause.
- buildUse functional updates when the new state depends on the old: `setCount(prev => prev + 1)`
- buildInclude all referenced state and props in useCallback and useMemo dependency arrays
- buildUse controlled inputs: set `value={state}` and `onChange={(e) => setState(e.target.value)}`
- buildFor callbacks that must always see the latest state, use a ref to store the latest value and read from the ref
- buildUse the React DevTools profiler to see which renders produce which state values
- buildConsider using useReducer for complex form state where multiple values depend on each other
A fix you cannot prove is a guess. Close the loop.
- verifiedType in a form field. Submit. The submitted value should match what is in the input.
- verifiedTest rapid interactions: type quickly, submit, type again. No stale values should be submitted.
- verifiedAdd a useEffect that logs the state value. It should always log the current value.
- verifiedTest the form with React Strict Mode enabled — it surfaces stale closure bugs by double-invoking effects.
- verifiedWrite a test that fills the form and submits, asserting the submitted payload matches the input.
Things that make this bug worse or harder to find.
- warningUsing an empty dependency array on useCallback as a 'performance optimisation' without understanding the trade-off
- warningUsing defaultValue instead of controlled value + onChange for form inputs that need to reflect state changes
- warningStoring derived state instead of computing it from props or other state
- warningNot testing forms with React Strict Mode enabled
- warningUsing setTimeout with a callback that closes over state without checking if the component is still mounted