setCount(count+1) inside setTimeout, async, or event handlers reads the old count. Fix: setCount(c => c + 1).state.items.push(x); setState(state) doesn't re-render — same reference. Fix: spread into a new array.setCount(5); console.log(count) still logs the old value. Fix: read inside useEffect or use a local var.useReducer.You call setCount(count + 1). Next line, console.log(count) still prints the old number. Or you click a button and the UI just doesn't re-render — the value is stuck. React useState not updating is the second-most-asked React question on Stack Overflow with about 929,000 views, and it almost always comes from one of four patterns. After eight years writing TypeScript and React — including running MultiCurrencyWallet (540⭐), where every one of these has bitten me — here is the short list, with fixes, and why your AI assistant keeps generating each of them wrong.
Click "Increment (broken)" — counter stays at 0. Then click "Fix It →" to watch VibeCheck capture and patch it. Toggle "Show fixed version" to see the working code.
This is the one Cursor, Copilot, and Claude generate at least once per session. The pattern looks innocent — a click handler that increments a counter, debounces input, or schedules a delayed update.
function SearchBox() {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
setCount(count + 1); // captures count from this render
}, 500);
};
return <button onClick={handleClick}>Click me ({count})</button>;
}
Click the button five times in a row. You expect the counter to land on 5. It lands on 1. Each setTimeout callback closed over the same count = 0 from the render where it was created — so all five compute 0 + 1 and ship the same value to React.
I shipped this exact pattern in src/front/shared/pages/Exchange/QuickSwap/InputForm.tsx — a debounced input handler that scheduled setFlagForLazyChanges(false) inside a 600 ms setTimeout. The first version read other state values inside the timeout and missed updates that arrived between the keypress and the timer firing. The fix was the functional updater.
const handleClick = () => {
setTimeout(() => {
setCount(c => c + 1); // always reads latest committed state
}, 500);
};
The callback form receives the most recent committed value from React's internal store, not the one captured in the render's closure. This single fix resolves around 70% of "react state not updating" reports I see on client review calls.
The next-most-common bug. State holds an object or array, you mutate it in place, then call the setter with the same reference. React compares the new state to the old with Object.is, sees the same reference, and skips the re-render.
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
todos.push({ text, done: false }); // mutates the same array
setTodos(todos); // same reference → no re-render
};
The data is technically there — if you log todos on the next render (triggered by something else), the new item is present. But React never schedules a render in response to setTodos because the reference didn't change. Users see an unresponsive UI.
const addTodo = (text) => {
setTodos(prev => [...prev, { text, done: false }]);
};
Same trick for objects: setUser(prev => ({ ...prev, name })). Use Immer (useImmer) if you have nested updates more than two levels deep — it gives you mutable-looking syntax that produces new references under the hood.
The classic console.log frustration:
const handleSubmit = () => {
setCount(5);
console.log(count); // still 0 — old render's closure
analytics.track('count', count); // sends 0, not 5
};
This isn't a bug in React — it's a bug in your mental model. count in this scope is a const created when this render ran. setCount doesn't reach in and mutate it. It tells React: "next time you render, give the next call of this function a count equal to 5." The current function call still has the old constant.
const handleSubmit = () => {
const next = 5;
setCount(next);
analytics.track('count', next); // log the value you just set
};
// or, if you must read after React commits:
useEffect(() => {
analytics.track('count', count);
}, [count]);
Every "setState not working" question I have ever helped debug in DM had this confusion as the root cause at least 30% of the time. The state did update. The render did happen. The console.log just lives in the wrong scope to see it.
Two setters fire in the same handler. The second computes its next value from a stale read of the first.
const [count, setCount] = useState(0);
const [doubled, setDoubled] = useState(0);
const bump = () => {
setCount(count + 1);
setDoubled((count + 1) * 2); // reads same stale count
};
If you click twice fast, both renders compute from count = 0. Counter ends at 1, doubled ends at 2. Worse: doubled isn't really independent state — it's derived. Storing it duplicates the source of truth and creates a class of bugs that no setter form will ever fully eliminate.
const [count, setCount] = useState(0);
const doubled = count * 2; // derived, always in sync
const bump = () => setCount(c => c + 1);
If state genuinely is independent but updates together — say a wizard with current step + validation errors + draft data — switch to useReducer. One dispatch produces one new state. No race.
This isn't an attack on AI tools — I use Claude Code daily and ship features faster because of it. But there's a specific reason these four bugs show up in AI-generated React code more than in hand-written code.
The training data is biased toward old React. A huge slice of React tutorials, blog posts, and Stack Overflow answers from 2017–2020 use setCount(count + 1) as the canonical example because it's pedagogically simpler — readers see count referenced, not a callback. The functional updater form was always available, but it became recommended only after Strict Mode and Concurrent Features made stale-closure bugs more visible. By then the corpus was full of the old pattern.
AI completion engines pattern-match locally. They see const [count, setCount] = useState(0) and complete with setCount(count + 1) because that's the most-frequent token sequence. They don't model the closure scope of the calling site — whether you're in an event handler, a setTimeout, or an async function. The bug is a closure-scope problem; the suggestion is a token-frequency answer. Same shape, different category.
Direct mutation has a similar root: most non-React JS code mutates freely. arr.push, obj.foo = bar — these are fine in 99% of JavaScript. React is the unusual environment where they're wrong. AI tools generate the JavaScript-idiomatic code, not the React-idiomatic code, unless the surrounding context strongly cues "this is state."
The bug demo at the top of this article shows the workflow we built into VibeCheck: a free Chrome extension that captures broken React state on the page exactly as the user sees it.
Honest version: AI is genuinely good at the four patterns above. They're local, the fix is small, and the diff is verifiable in a few seconds of reading. AI is not good at deciding whether your useEffect dependency array is missing a value because it's an oversight or because adding it would cause an infinite loop. That's where a human reviewer earns their keep.
VibeCheck captures the broken React state in one click. Free Chrome extension. No signup until you want a human reviewer.
Try VibeCheck free →Object.is to detect changes and skips re-render; (3) you reference state inside a closure (setTimeout, event handler, async function) that captured the old value — known as a stale closure; (4) two setters race within the same render. The fix depends on which pattern hit you.setCount(5) still sees the old count because the function-scoped variable was captured when the component rendered. To read the new value, use a useEffect that depends on the state, or pass a callback to the setter: setCount(c => c + 1).console.log runs in the current render's closure, which captured the state value as a local constant. Calling setState schedules a new render but does not mutate the existing variable. To log the new value, put the console.log inside a useEffect that depends on the state, or log the value you just passed to the setter rather than the state variable.setCount(prev => prev + 1) instead of setCount(count + 1). The callback receives the most recent committed state, so it survives stale closures inside setTimeout, event handlers, async functions, and rapid sequential calls. This single pattern fixes about 70% of useState-not-updating bugs in production code.useReducer when (a) state has more than three or four related fields that change together, (b) the next state depends on multiple previous fields, or (c) you have multiple setters firing in the same handler and racing each other. useReducer centralizes transitions in a pure function, which eliminates the multi-setter race class of bugs and makes state changes traceable in React DevTools.