Cause 1: logging state straight after setting it
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Still 0 from this render
}
This log is showing the value that belonged to the render which created handleClick. React calls the component again with the updated count, but the existing handler’s local binding does not change.
To observe committed changes, log during rendering or use an effect that depends on the value:
useEffect(() => {
console.log('count is now', count);
}, [count]);
Cause 2: several updates in one handler
// Adds 1 because every call reads the same count
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Each updater receives the latest pending value
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1); // Now +3
React batches these updates into one render. Each direct call calculates the same value from the count captured by the handler, so setting that value three times still adds only one. The updater function receives the latest pending value, allowing React to apply each increment in sequence.
Use the updater form when the next value depends on the previous one. Besides handling queued updates correctly, it can remove an otherwise unnecessary state dependency from an effect or callback.
Cause 3: mutating state instead of replacing it
React compares the previous and next state with Object.is. Mutating an object or array keeps the same reference, so React can skip the render even though properties or elements changed in memory.
// The same array reference can skip rendering
items.push(newItem);
setItems(items);
// The same object reference has the same result
user.name = 'Ada';
setUser(user);
// New references trigger rendering
setItems([...items, newItem]);
setUser({ ...user, name: 'Ada' });
For a nested update, create a new reference at each changed level. Reusing one inner object is still a mutation of the previous state:
// The outer object is new, but settings is reused
setUser({ ...user, settings: Object.assign(user.settings, { theme: 'dark' }) });
// Every changed level receives a new object
setUser({
...user,
settings: { ...user.settings, theme: 'dark' },
});
// map and filter return new arrays; sort and reverse mutate in place
setItems(items.map(i => i.id === id ? { ...i, done: true } : i));
setItems([...items].sort((a, b) => a.order - b.order)); // Copies before sorting
For deeply nested state, Immer (useImmer) can make immutable updates easier to read. It is also worth checking whether the component needs that entire nested object; flatter or more local state is usually simpler to update.
Cause 4: stale closures in effects, intervals and callbacks
Stale closures often look plausible because the callback works on its first run and then stops seeing later values.
// Logs 0, 1, 1, 1, 1 … then stops
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count stays 0 in this closure
}, 1000);
return () => clearInterval(id);
}, []); // Empty dependencies prevent another run
The effect runs once and captures count as 0. Its interval callback keeps that closure, so every tick calculates 0 + 1. Choose a fix based on whether the callback needs the state value for anything beyond the update:
// The updater never reads the captured variable
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
// Declaring the dependency recreates the subscription
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]); // Recreates the interval after every tick
For this counter, the updater form is preferable because the interval can remain subscribed while each update receives the latest pending state.
The same issue appears in event listeners, WebSocket handlers and other callbacks registered for a long time. When such a callback needs the latest value without being re-registered, keep that value in a ref:
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
const socket = new WebSocket(url);
socket.onmessage = () => {
console.log('current count:', countRef.current); // Reads the current value
};
return () => socket.close();
}, [url]);
Cause 5: state that is not really state
Some apparent update failures come from storing a value that should not be independent state.
Initialising state from props
// Runs once, so later prop changes are ignored
function Profile({ user }) {
const [name, setName] = useState(user.name);
// Component logic continues here
}
The argument to useState is an initial value. A later prop change does not reset that state. Derive the displayed value from the prop when no local edits are needed; when the component truly needs fresh local state for each record, a changing key can remount it:
// A new key discards the old component and its state
<Profile key={user.id} user={user} />
Storing derived values in state
// Two sources of truth can disagree
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
// One source of truth lets rendering derive the rest
const [items, setItems] = useState([]);
const total = items.reduce((sum, i) => sum + i.price, 0);
A value that can be calculated from current state does not usually need its own setter. Storing both sources creates a synchronization problem and often adds an effect plus another render. Use useMemo only when measurement shows that the calculation is expensive enough to cache.
A quick diagnostic
- Does the component render again? Put a temporary log at the top of the component. If no render follows the setter, check whether the old object or array reference was passed back.
- Does the next render still use an old value? Look for a long-lived closure and inspect the dependency arrays of effects and callbacks.
- Do several updates produce only one change? Use an updater function when each call depends on the previous value.
- Does the value reset unexpectedly? Check whether a changing
keyremounts the component or whether state was initialised from a prop. - Inspect the component in React DevTools. The Components panel shows whether the state committed even when a log comes from an older closure.
Frequently asked questions
Why does console.log show the old state right after setState?
The function is reading the state value captured by the render that created it. setState schedules another render with a new value; it does not change the existing local binding. Log in an effect that depends on the value when you need to observe committed updates.
How do I run code after state updates?
Use an effect with the state in its dependency array: useEffect(() => { … }, [count]). There is no callback argument to the useState setter as there was with this.setState in class components.
Why does calling setCount three times only add one?
Each direct call reads the same count and calculates the same next value. Use setCount(c => c + 1) so each queued updater receives the latest pending value.
Why does my component not re-render when I push to an array in state?
push mutates the existing array, so the reference is unchanged and React’s Object.is comparison sees no difference. Create a new array: setItems([...items, newItem]).
When should I use useRef instead of useState?
Use a ref for a value that must persist across renders without triggering another render, such as a timer ID, DOM node, previous value or current data needed by a long-lived callback. Keep displayed data in state because changing a ref does not update the UI.
Why does my effect run twice in development?
Development Strict Mode mounts, unmounts and remounts components to expose effects without adequate cleanup. This check does not run the same way in production. If remounting breaks the effect, add the cleanup needed to make setup repeatable.