
Stale Closures in React
A stale closure is a function that captured a value from a previous render and still references that old value. It’s one of the most common sources of subtle bugs in React hooks.
The Basic Problem
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count is always 0 — captured on mount
}, 1000);
return () => clearInterval(id);
}, []); // empty deps: effect runs once, closes over count=0
return <div>{count}</div>;
} The count never gets past 1. The interval callback captured count = 0 on mount and never gets a fresh copy.
Fix 1: Functional Update
Use the updater function form to avoid reading stale state:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // receives current value at dispatch time
}, 1000);
return () => clearInterval(id);
}, []); The updater c => c + 1 receives the current state at the moment it’s dispatched, not at capture time. No stale closure.
Fix 2: Correct Dependencies
Add the captured value to the dependency array:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]); // effect re-runs when count changes This creates a new interval on every count change — correct, but potentially not the intended behavior.
Fix 3: useRef for Mutable Values
For callbacks that need the latest value without re-running the effect:
function useLatest(value) {
const ref = useRef(value);
useEffect(() => { ref.current = value; });
return ref;
}
function Counter() {
const [count, setCount] = useState(0);
const latestCount = useLatest(count);
useEffect(() => {
const id = setInterval(() => {
setCount(latestCount.current + 1); // always current
}, 1000);
return () => clearInterval(id);
}, []);
} Stale Closures in Event Handlers
function App() {
const [message, setMessage] = useState('hello');
const handleClick = useCallback(() => {
console.log(message); // stale if message is missing from deps
}, []); // forgot to add message
} Adding message to useCallback deps fixes it but regenerates the callback on every change — trade-off between freshness and stability.
useReducer Avoids the Problem
useReducer dispatch is always stable and the reducer always receives current state:
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'INCREMENT' }); // no stale closure — no state read
}, 1000);
return () => clearInterval(id);
}, []); 








