React components frequently use callbacks, effects, and timers. These features are convenient, but they can introduce a subtle bug: a function may continue using a value captured during an earlier render.
This is commonly known as a stale closure.
What Is a Stale Closure?
Consider this component:
import { useEffect, useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
The effect runs once because its dependency array is empty. The interval callback captures the initial value of count, so it continues logging that value rather than the latest state.
The issue is not that React state updates incorrectly. The callback retains the variables available when it was created.
Include the Correct Dependencies
One solution is to include count in the dependency array:
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, [count]);
Now React cleans up the previous interval and creates a new one whenever count changes.
This is appropriate when the effect genuinely needs to react to the changing value. However, repeatedly recreating a timer may not always be the desired behavior.
Use Functional State Updates
When the goal is to update state based on its previous value, use a functional update:
setCount(previousCount => previousCount + 1);
This avoids relying on a potentially outdated state value from the surrounding closure.
For example:
useEffect(() => {
const timer = setInterval(() => {
setCount(previousCount => previousCount + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
The interval can remain stable while each update receives the latest state value.
Understand When Refs Help
A ref can hold a mutable value that persists across renders without triggering another render.
const latestValue = useRef(value);
useEffect(() => {
latestValue.current = value;
}, [value]);
A long-lived callback can read latestValue.current when it executes.
Refs are useful for certain imperative integrations, but they should not replace normal state or effect dependencies indiscriminately. If an effect needs to synchronize with changing props or state, its dependencies should accurately describe that relationship.
Avoid Suppressing Dependency Warnings
Ignoring the React Hooks dependency lint rule may appear to fix a warning, but it can conceal a real correctness issue.
Instead, ask:
- Should this effect rerun when the value changes?
- Can the state update use a functional updater?
- Should the callback be recreated?
- Is a ref appropriate for a mutable value used by an external system?
Recent React versions also provide Effect Events for specific cases where logic inside an effect must read the latest values without making the effect reactive to those values. Use that API according to its documented constraints rather than as a general dependency escape hatch.
Conclusion
Stale closures become easier to diagnose when you understand that functions capture values from a particular render. Choose dependencies based on synchronization requirements, use functional updates for previous-state calculations, and reserve refs for appropriate imperative cases.
These practices make React effects and callbacks more predictable.