How to Fix useState Setter Not Updating State Immediately in React Event Callbacks
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke: You called
setState(newVal)and readstateon the very next line — you got the old value because React batches state updates and the closure captured the pre-render value. - How to fix it: Use the functional updater form
setState(prev => ...), derive the new value into a local variable and use that variable instead of the state reference, or shift post-update logic into auseEffectwatching the state variable. - Use our Client-Side Sandbox below to auto-refactor this — paste your component and get the corrected closure-safe version instantly.
The Incident (What does the error mean?)
No exception is thrown. That is what makes this brutal. Your console stays clean while your UI silently operates on wrong data.
Raw symptom in code:
// Inside a click handler
setCount(count + 1);
console.log(count); // ← still logs OLD value
if (count > 5) { // ← condition never fires correctly on first trigger
submitForm();
}
Immediate consequence: Any logic placed after the setter call that depends on the updated value — conditional branches, derived calculations, API calls with the new state as payload — executes against the stale closure snapshot captured when the component last rendered. The UI may eventually display the correct value after re-render, but the side-effect logic already fired with bad data.
The Attack Vector / Blast Radius
This is not a cosmetic bug. The blast radius scales with what you do after the setter:
- Form submission with stale payload: User increments a quantity field, hits Submit. The POST body carries the pre-click quantity because the handler read
countinstead ofcount + 1. - Cascading conditional logic: A multi-step wizard checks
if (step === 3) lockFields()immediately aftersetStep(3).stepis still2. Fields never lock. Data integrity is compromised. - Race conditions under React 18 concurrent features: With automatic batching in React 18, multiple setter calls in async callbacks are batched together. Any interleaved reads between those calls are guaranteed stale — the re-render hasn't happened yet.
- Infinite loops via useEffect misuse: Engineers who discover the stale read problem and "fix" it by adding the setter inside a
useEffectwithout proper dependency arrays create infinite render loops that peg the CPU at 100% and make the tab unresponsive.
The core reason: useState state variables are constants within a render cycle. The setter schedules a re-render; it does not mutate the variable in place. The event handler is a closure over the render-cycle-scoped constant.
How to Fix It (The Solution)
Basic Fix — Capture the new value in a local variable
Stop reading from the state variable after the setter call. Compute the new value first, use it everywhere in the handler, then pass it to the setter.
- const handleClick = () => {
- setCount(count + 1);
- if (count > 5) { // stale read
- submitForm(count);
- }
- };
+ const handleClick = () => {
+ const nextCount = count + 1; // compute once
+ setCount(nextCount); // schedule re-render
+ if (nextCount > 5) { // use local var, not state
+ submitForm(nextCount);
+ }
+ };
Enterprise Best Practice — useEffect for post-state side effects
For side effects that must run after React has committed the new state (e.g., DOM measurements, dependent API calls), move the logic into a useEffect. This is the only pattern that is guaranteed to run with the committed value.
- const handleClick = () => {
- setCount(count + 1);
- syncWithServer(count); // fires with stale count
- };
+ const handleClick = () => {
+ setCount(prev => prev + 1); // functional updater, no stale closure risk
+ };
+
+ useEffect(() => {
+ if (count > 0) {
+ syncWithServer(count); // guaranteed to have committed value
+ }
+ }, [count]); // runs after every count change
For synchronous reads across async gaps — useRef as a mirror
When you need the latest value inside setTimeout, setInterval, or a Promise callback, useRef is the escape hatch because refs are mutable and not closure-scoped.
+ const countRef = useRef(count);
+
const handleClick = () => {
- setCount(count + 1);
+ const next = count + 1;
+ countRef.current = next; // sync the ref before async work
+ setCount(next);
setTimeout(() => {
- console.log(count); // always stale inside setTimeout
+ console.log(countRef.current); // always current
}, 1000);
};
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of bug is statically detectable. Lock it out of your codebase permanently:
1. ESLint react-hooks/exhaustive-deps (non-negotiable baseline)
This rule flags stale closure risks in useEffect and useCallback. Enable it as an error, not a warning.
// .eslintrc.json
{
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
2. Custom ESLint rule or no-restricted-syntax to ban post-setter reads
For teams with a zero-tolerance policy, write a custom lint rule that detects when the same identifier used as the argument to a setState call is read on a subsequent line within the same function scope.
3. Block merges in CI with lint gates
# .github/workflows/ci.yml
- name: Lint
run: npx eslint src/ --max-warnings=0 # zero warnings allowed
4. React Testing Library assertion pattern
In unit tests, always assert state-dependent behavior after await userEvent.click() and wrap assertions in waitFor. A synchronous assertion after a user event is the test equivalent of the same bug.
- fireEvent.click(button);
- expect(screen.getByText('6')).toBeInTheDocument(); // may pass for wrong reasons
+ await userEvent.click(button);
+ await waitFor(() => {
+ expect(screen.getByText('6')).toBeInTheDocument();
+ });
5. TypeScript strict mode does not catch this — do not rely on type safety here. The setter accepts the correct type whether the value is stale or fresh. ESLint is your only static analysis layer for this specific pattern.