April 18, 2026 10 min read Blog · React & Vibe Coding

React useState Not Updating: 4 Patterns AI Keeps Generating Wrong (and How to Fix Each)

Key Takeaways

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.

localhost:3000/dashboard
V

Counter Component

Clicks
0
clicks: 0 · ui shows: 0
- setCount(count + 1) // stale closure + setCount(c => c + 1) // functional updater
REC
Selected
<button.bug-counter-btn>
Increment (broken)
div.bug-counter-card > button
Comment
|
✓ PR opened in 47 min - setCount(count + 1) + setCount(c => c + 1) Stale closure → functional updater. PR #312 MERGED
1
2
3

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.

Pattern 1 — Stale Closure (the AI Favorite)

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.

Buggy — stale closure
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.

Fixed — 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.

Pattern 2 — Mutating State Directly

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.

Buggy — direct mutation
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.

Fixed — return a new array
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.

Pattern 3 — Reading State Synchronously After Set

The classic console.log frustration:

Buggy — sync read
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.

Fixed — use the value you set, or read in useEffect
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.

Pattern 4 — Multiple Setters in One Render

Two setters fire in the same handler. The second computes its next value from a stale read of the first.

Buggy — race between setters
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.

Fixed — derive instead of store, or use useReducer
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.

Why AI Keeps Generating This

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 30-Second Workflow with VibeCheck

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.

  1. Click the broken element. The extension grabs the DOM path, the React component name (from React DevTools fiber), the URL, the viewport, the browser, and any console errors fired in the last 10 seconds.
  2. Type the symptom in plain English. "Counter doesn't increment past 1" or "Submit button doesn't react after the second click." No template, no severity dropdown.
  3. Send. If the bug is a stale closure, mutation, or a sync-read confusion, Claude can usually generate the patch and open a PR within a few minutes. If it's a deeper architectural issue — multi-component state race, derived state stored in three places, useEffect dependency loop — it goes to a Vibers human reviewer ($15/hour) who looks at it within a working day.

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.

Stop debugging stale state by hand

VibeCheck captures the broken React state in one click. Free Chrome extension. No signup until you want a human reviewer.

Try VibeCheck free →

Frequently Asked Questions

Why doesn't my useState update?
useState appears not to update for one of four reasons: (1) you read state synchronously after calling the setter — React schedules the update for the next render, so the local variable still holds the old value; (2) you mutate the existing state object instead of returning a new one — React uses 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.
Is useState async?
useState's setter is asynchronous in the sense that React batches updates and commits them on the next render, not immediately. The line after 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).
Why do I see the old state in console.log?
Because the 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.
How do I update state from a previous value?
Use the functional updater form: 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.
When should I use useReducer instead?
Switch to 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.
Can AI tools fix useState bugs automatically?
For stale-closure bugs, yes — AI tools like Cursor, Claude Code, and Copilot can suggest the functional updater fix once you point them at the broken handler. For deeper issues (multi-component state races, derived state that should be computed instead of stored, useEffect dependency loops), AI suggestions often introduce new bugs. The reliable workflow is: let AI propose the fix, then have a human reviewer verify it doesn't break sibling components or trigger an effect cascade.

Alexander "Noxon" Ruin

AI Systems Design Consultant, founder of onout.org. Maintainer of MultiCurrencyWallet (540⭐ on GitHub) since 2018, plus the NFTsy WordPress plugin and the Unifactory DEX fork. 8+ years writing TypeScript and React in production. Every one of these four useState patterns has bitten me — usually right before a demo.

GitHub onout.org Telegram