React state updates not reflecting in the UI is one of the most frustrating bugs new and experienced React developers encounter. The root cause is almost always one of seven predictable issues — from direct mutation to stale closures. This guide walks through every cause with a broken example and the exact fix.
Quick diagnosis: if the state value looks correct in React DevTools but the UI does not update, you have a rendering issue. If DevTools also shows the old value, the state is not being updated at all — check causes 1, 3, and 4 below.
Cause 1: Mutating State Directly
React uses shallow reference equality to decide whether to re-render. If you mutate an array or object in place, the reference stays the same, so React sees no change and skips the re-render entirely.
Wrong — direct mutation (no re-render):
// ❌ Mutating the array directly
const [items, setItems] = useState(['apple', 'banana']);
function addItem() {
items.push('cherry'); // mutates the same array reference
setItems(items); // React sees the same ref → no re-render
}
// ❌ Mutating an object directly
const [user, setUser] = useState({ name: 'Alice', age: 30 });
function birthday() {
user.age += 1; // mutates the same object reference
setUser(user); // React sees the same ref → no re-render
}Fixed — always return a new reference:
// ✅ Spread to create a new array
function addItem() {
setItems(prev => [...prev, 'cherry']);
}
// ✅ Filter returns a new array
function removeItem(index) {
setItems(prev => prev.filter((_, i) => i !== index));
}
// ✅ Spread to create a new object
function birthday() {
setUser(prev => ({ ...prev, age: prev.age + 1 }));
}Cause 2: State Updates Are Asynchronous
Calling setState does not immediately update the variable. React schedules the update and re-renders the component. Reading the variable right after calling setState will still show the old value.
Wrong — reading state right after setting it:
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // ❌ still logs 0 — update hasn't happened yet
}Fixed — use useEffect to observe the new value:
const [count, setCount] = useState(0);
// ✅ useEffect runs after the render with the new value
useEffect(() => {
console.log('New count:', count); // logs the updated value
}, [count]);
function handleClick() {
setCount(prev => prev + 1);
// Do not read count here — read it in the next render
}Cause 3: Stale Closure in useEffect or Callbacks
A closure captures the values of variables at the time it is created. If a useEffect or an event handler closes over a state variable and that variable later changes, the function still uses the old captured value — this is a stale closure.
Bug — counter stuck at 1 because of stale closure:
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // ❌ 'count' is always 0 (captured at mount)
}, 1000);
return () => clearInterval(interval);
}, []); // empty deps — closure never refreshesFix 1 — functional updater (preferred):
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // ✅ always uses latest state
}, 1000);
return () => clearInterval(interval);
}, []);Fix 2 — add to dependency array (re-creates effect):
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // ✅ count is fresh each time
}, 1000);
return () => clearInterval(interval);
}, [count]); // interval is recreated when count changesCause 4: Object/Array Reference Did Not Change
React compares state with Object.is(). If you pass the exact same object or array reference to setState, React bails out of re-rendering even if the contents changed internally.
Wrong — same reference passed to setState:
const [list, setList] = useState([1, 2, 3]);
function update() {
list[0] = 99; // mutates in place
setList(list); // ❌ same reference — no re-render
}
// Also wrong with objects
const [config, setConfig] = useState({ theme: 'light' });
function toggle() {
config.theme = 'dark'; // ❌ mutation
setConfig(config); // ❌ same reference
}Fixed — always produce a new reference:
function update() {
setList(prev => prev.map((v, i) => (i === 0 ? 99 : v))); // ✅ new array
// Or with spread
const newList = [...list];
newList[0] = 99;
setList(newList); // ✅ new reference
}
function toggle() {
setConfig(prev => ({ ...prev, theme: 'dark' })); // ✅ new object
}Cause 5: Setting State in the Wrong Component
React state lives in a component and only re-renders that component and its children. If a sibling or parent component needs the updated value, you must lift state up to the nearest common ancestor.
Wrong — sibling cannot see the state:
// ❌ Counter state is isolated to this component
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
function Display() {
// ❌ Cannot read count — it lives in Counter
return <p>Count is: ???</p>;
}
function App() {
return <><Counter /><Display /></>;
}Fixed — lift state to the common parent:
// ✅ State lives in App and is passed down as props
function Counter({ count, onIncrement }) {
return <button onClick={onIncrement}>{count}</button>;
}
function Display({ count }) {
return <p>Count is: {count}</p>;
}
function App() {
const [count, setCount] = useState(0);
return (
<>
<Counter count={count} onIncrement={() => setCount(c => c + 1)} />
<Display count={count} />
</>
);
}Cause 6: React 18 Automatic Batching
In React 18, all state updates are batched automatically — even those inside setTimeout, Promises, and native event handlers. This means three consecutive setState calls cause only one re-render. This is a performance improvement but can be surprising if you expect each call to trigger a separate render.
Surprising — only one re-render for three setState calls:
// React 18: all three batched into one render
async function fetchData() {
const data = await api.get('/user');
setUser(data.user); // batched
setLoading(false); // batched
setError(null); // batched
// ← only ONE re-render happens after this function resolves
}Opt out when you need separate renders (rare):
import { flushSync } from 'react-dom';
// ✅ Forces a synchronous render after each call
flushSync(() => setUser(data.user));
flushSync(() => setLoading(false));
// Use sparingly — batching is almost always what you wantCause 7: Updating Object State Without Spreading
Unlike this.setState in class components, the useState setter replaces the entire state value. If you pass a partial object, all other keys are lost.
Wrong — overwrites the entire state object:
const [user, setUser] = useState({ name: 'Alice', age: 30, role: 'admin' });
function updateName(newName) {
setUser({ name: newName }); // ❌ age and role are now gone!
// State becomes: { name: 'Bob' }
}Fixed — spread existing state, then override:
function updateName(newName) {
setUser(prev => ({ ...prev, name: newName })); // ✅ preserves age and role
}
// Or for deeply nested state consider useReducer:
const [user, dispatch] = useReducer(userReducer, initialUser);
dispatch({ type: 'SET_NAME', payload: newName });When to Use the Functional Updater Form
The functional updater setState(prev => newValue) guarantees you always read the latest state, even if the component re-renders between enqueued updates. Use it whenever:
- The new value depends on the previous value
- You are updating state inside a timeout, interval, or Promise
- You are updating state inside a useEffect or an event handler that may be stale
- Multiple updates are batched together and order matters
// Always safe — never stale
setCount(prev => prev + 1);
setItems(prev => [...prev, newItem]);
setUser(prev => ({ ...prev, name: 'Bob' }));
setFlags(prev => ({ ...prev, isLoading: false }));Debugging React State
React DevTools
Install the React DevTools browser extension. Select any component in the Components tab to see its state and props in real time. You can even edit state values directly to test UI changes without code.
Log state in useEffect, not inline
// ❌ Logs old value synchronously
setCount(count + 1);
console.log(count);
// ✅ Logs updated value after re-render
useEffect(() => {
console.log('count updated to:', count);
}, [count]);Render count trick
const renderCount = useRef(0);
renderCount.current += 1;
console.log('Renders:', renderCount.current);
// If this climbs fast → you likely have an infinite loopFormat and validate your API response JSON
Malformed API responses are a hidden cause of undefined state bugs. Validate your JSON structure before wiring it into React state.
Open JSON Formatter →Frequently Asked Questions
Why does console.log show old state after setState?
setState is asynchronous. The variable in your closure still holds the old value for the rest of that synchronous execution. Use useEffect with the state as a dependency to observe the updated value.
Does React batch state updates?
Yes. React 18 batches all state updates automatically, even inside setTimeout and Promises. Multiple setState calls in one function produce a single re-render.
How do I fix stale closure in useEffect?
Use the functional updater form: setState(prev => prev + 1). This always receives the latest state regardless of when the closure was created.
Why is my array state not updating?
You are likely mutating the array in place. Use spread: setItems(prev => [...prev, newItem]) to always return a new reference that React can detect.
When should I use functional updater form?
Whenever the new value depends on the previous value — especially inside useEffect, timers, async functions, or event handlers that could capture a stale value.