A common React pitfall is trying to access an updated state value immediately after calling setState
or the state setter from useState
. This doesn’t work because state updates are asynchronous in React.
The Problem
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // ❌ Will log the old value
};
return <button onClick={handleClick}>Count: {count}</button>;
}
Why this happens:
- State updates are asynchronous
- React batches state updates for performance
- The
count
variable is constant within each render - Console.log captures the value at call time
Correct Solutions
1. Use the Value in Rendering (Recommended)
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // Update state
};
// ✅ Correct - The render will show updated state
return <button onClick={handleClick}>Count: {count}</button>;
}
2. Use Functional Updates for Calculations
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// ✅ Get previous state in functional update
setCount(prevCount => {
const newCount = prevCount + 1;
console.log(newCount); // Logs correct value
return newCount;
});
};
return <button onClick={handleClick}>Count: {count}</button>;
}
3. Use useEffect for Side Effects
function Counter() {
const [count, setCount] = useState(0);
// ✅ Runs after count changes
useEffect(() => {
console.log('Count updated:', count);
}, [count]);
const handleClick = () => {
setCount(count + 1);
};
return <button onClick={handleClick}>Count: {count}</button>;
}
Class Component Behavior
The same applies to class components:
class Counter extends React.Component {
state = { count: 0 };
handleClick = () => {
this.setState({ count: this.state.count + 1 });
console.log(this.state.count); // ❌ Old value
};
// ✅ Use callback for post-update logic
handleClickCorrect = () => {
this.setState(
{ count: this.state.count + 1 },
() => console.log(this.state.count) // ✅ Updated value
);
};
render() {
return <button onClick={this.handleClick}>Count: {this.state.count}</button>;
}
}
Why State Updates Are Asynchronous
React batches state updates because:
- Performance optimization
- Consistency between props and state
- Avoid unnecessary re-renders
- Prepare for concurrent rendering features
Common Mistakes
- Multiple consecutive updates:
const [value, setValue] = useState(0);
const handleClick = () => {
setValue(value + 1);
setValue(value + 1); // ❌ Both use same stale value
};
- Using state for calculations:
const [count, setCount] = useState(0);
const doubleCount = count * 2; // ✅ Safe (derived state)
const handleClick = () => {
setCount(count + 1);
console.log(doubleCount); // ❌ Based on stale count
};
- Async operations with stale state:
const [data, setData] = useState([]);
const fetchData = async () => {
const result = await fetch('/api');
setData(result);
processData(data); // ❌ Uses old data
};
Best Practices
- Treat state as immutable – Never mutate directly
- Use functional updates when new state depends on old state
- Put side effects in useEffect to respond to changes
- Extract values before async operations if needed
- Use useReducer for complex state logic
Advanced Patterns
1. Refs for Current Values
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // Update ref on change
}, [count]);
const handleClick = () => {
const newCount = count + 1;
setCount(newCount);
someFunction(countRef.current); // ✅ Gets latest value
};
}
2. Custom Hook for Derived State
function useDerivedState(initialValue, transform) {
const [state, setState] = useState(initialValue);
const derived = useMemo(() => transform(state), [state]);
return [state, setState, derived];
}
// Usage
function Component() {
const [count, setCount, doubled] = useDerivedState(0, n => n * 2);
// doubled always in sync
}