What this usually means
React’s setState is asynchronous, and state updates are batched. If your code relies on the new state immediately after calling setState, or if you close over stale values (common with event handlers or effects), the component may not reflect the changes you expect. In addition, returning the same state value, mutating state objects, or skipping dependencies in hooks can all cause state to appear 'stuck.'
The first ten minutes — establish facts before touching code.
- 1Add a console.log after setState and inside useEffect to compare values: `console.log('after setState:', state);`
- 2Verify which variable you're reading: is it the latest state, or a closure?
- 3Check if state is a primitive or an object/array—did you mutate it directly?
- 4Inspect the function form of setState to see if it fixes the issue: `setState(prev => prev + 1)`
- 5Ensure your component actually re-renders: add a log or use React DevTools 'Highlight Updates'
- 6Double-check effect dependencies if using useEffect—missing values can lock in stale state
The specific files, logs, configs, and dashboards that usually own this bug.
- searchThe component file where setState is called (e.g. Counter.js)
- searchEvery event handler or callback using state (onClick, useEffect, custom functions)
- searchReact DevTools: Components tab for live state inspection
- searchBrowser console logs for state snapshots after setState
- searchuseEffect dependency arrays for missing or extra values
- searchThe parent component, if state is passed via props
Practical causes, not theory. These are the things you will actually find.
- warningReading state immediately after setState (async update not finished)
- warningMutating state objects/arrays instead of replacing them
- warningEvent handler closures capturing old state
- warningEffect dependencies missing state variables
- warningReturning the same value from setState (no re-render)
- warningComponent memoization (React.memo, useMemo) hiding updates
Concrete fix directions. Pick the one that matches your root cause.
- buildAlways use the function form of setState when next state depends on previous: `setState(prev => prev + 1)`
- buildSpread or copy objects/arrays instead of mutating: `setState(prev => ({...prev, foo: 1}))`
- buildMove logic that needs current state into useEffect or into the setState callback
- buildReview all useEffect dependencies—auto-fix with eslint-plugin-react-hooks
- buildRemove stale memoization or force key change to trigger remount
- buildCheck if parent is controlling state via props—ensure updates propagate
A fix you cannot prove is a guess. Close the loop.
- verifiedUse React DevTools to see live state after your update
- verifiedAdd a log inside your component render to confirm re-renders
- verifiedTrigger your event and verify the UI changes accordingly
- verifiedAdd a useEffect on the state variable to log changes
- verifiedRun tests that check state-driven UI after the update
- verifiedComment out memoization (React.memo/useMemo) and verify
Things that make this bug worse or harder to find.
- warningDon’t assume state updates are synchronous—avoid reading state right after setState
- warningNever mutate state objects/arrays directly
- warningDon’t ignore function form of setState in loops or chained updates
- warningDon't skip items in useEffect dependency arrays
- warningAvoid combining state and props in a way that makes source of truth ambiguous
- warningDon’t overuse memoization without clear performance wins
State Stuck in React Counter After setState in Click Handler
Timeline
- 10:16User reports counter UI not incrementing on button click.
- 10:18Engineer adds console.log after setCount(count + 1); sees old value.
- 10:20Confirms button handler closes over initial count value due to stale closure.
- 10:22Tries useEffect—still logs old value after setCount.
- 10:24Switches to setCount(prev => prev + 1); state updates as expected.
- 10:27Removes direct state read after setCount; uses effect for follow-up.
- 10:30Issue confirmed fixed; deploys patch.
Our team got a bug report that the counter on our dashboard didn't update, despite clicking the increment button. My first thought was a rendering issue, but logging the state immediately after setCount showed the previous value, not the incremented one.
On closer inspection, I realized the click handler was closing over the original count variable and wasn't picking up the latest state. Even trying to log inside a useEffect, the value lagged behind, confirming that setState is asynchronous and the closure was stale.
Switching to the function form of setCount fixed the problem instantly. I made sure to update all similar handlers throughout the codebase, and added a test so stale closures wouldn't sneak back in. The lesson: always use the function form with setState when using current state, and never trust the value right after calling it.
Root cause
The click handler captured a stale count variable, so each setCount(count + 1) used the initial value instead of the latest.
The fix
Changed setCount(count + 1) to setCount(prev => prev + 1), breaking the stale closure and ensuring up-to-date state.
The lesson
Always use updater functions for setState when depending on current state; watch for closures in handlers.
Closures are notorious for causing subtle React bugs. If you define an event handler or a function inside your component, it inherits whatever state was current at definition time. If the function persists across renders (common with inline handlers or dependencies omitted from useEffect), it continues to reference old state, even as new renders occur.
Fix this by passing a function to setState: instead of setCount(count + 1), write setCount(prev => prev + 1). This ensures you always get the latest value, regardless of closure timing. Also, if you use custom hooks, always check what dependencies they close over.
Mutating arrays or objects returned by useState (e.g., state.push(item)) leads to React missing state changes, because the reference remains unchanged. React shallow-compares state; if the reference is unchanged, no re-render occurs.
Always replace arrays/objects with a new reference: e.g., setItems(items => [...items, newItem]). Never use .push(), .splice(), or direct property assignment.
If you skip state variables in useEffect dependencies, React will run effects with outdated state, causing desynchronization. For instance, useEffect(() => doSomething(state), []) will only run with the initial state.
Use ESLint with the react-hooks plugin to enforce correct dependencies, and avoid overusing React.memo or useMemo unless you have measured performance bottlenecks.
React DevTools lets you inspect live component state and props. Open the Components tab, select the buggy component, and watch changes as you trigger updates. If state doesn't change as expected, your issue is before the render phase.
Combine this with live console logging inside your component render and effects for bulletproof real-time feedback.
Frequently asked questions
Why does logging state right after setState show the old value?
setState is asynchronous and batched; the update happens after the event completes. Log state in a useEffect watching that variable, or after the next render.
When do I need the function form of setState?
Use setState(prev => ...) whenever your new value depends on the previous state, especially in event handlers, async code, or loops.
How can I catch stale closure problems?
Check if your function reads a variable defined outside itself. If that variable is state, rewrite your update using the function form or move the logic inside a useEffect.
Can mutating arrays or objects in state prevent re-renders?
Yes. React shallow-compares state; mutating in-place means the reference doesn't change, so React doesn't re-render. Always replace with a new object or array.
What tools help debug React state updates?
Use React DevTools for live inspection, and add console logs inside renders and effects. ESLint's react-hooks plugin helps catch dependency issues in effects.