This content originally appeared on DEV Community and was authored by Learcise
Have You Ever Experienced This When Using React?
- You call
setState
, but inside an event handler the value is still old - Inside a
setInterval
, readingstate
always gives you the initial value - “It should be updating, but nothing changes!”
One culprit behind this is the stale closure problem.
In this article, we’ll cover:
- Basics of scope and closures
- Why stale closures happen in React
- Typical examples where it occurs
- Ways to fix it
- The role of
useRef
1. A Refresher on Scope and Closures
What Is Scope?
Scope is “the range where a variable lives.”
For example, variables created inside a function cannot be accessed from outside.
function foo() {
const x = 10;
console.log(x); // 10
}
foo();
console.log(x); // ❌ Error: x doesn’t exist here
What Is a Closure?
A closure is “the mechanism where a function remembers the variables from the environment in which it was created.”
function outer() {
const message = "Hello";
function inner() {
console.log(message);
}
return inner;
}
const fn = outer();
fn(); // "Hello"
Normally, when outer
finishes, message
should disappear.
But since inner
remembers the scope at the time it was created, it can still access message
.
Think of a function as a time capsule carrying a box of variables from the moment it was created.
2. Why Stale Closures Happen in React
React components are functions, so a new scope is created on every render.
For example:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log("count:", count); // ← stays 0 forever
}, 1000);
return () => clearInterval(id);
}, []);
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
- Clicking the button updates
count
- But inside
setInterval
,count
remains the initial0
That’s because the closure created in useEffect([])
holds onto the initial scope forever.
In other words, you’re stuck with a stale closure — a closure trapped with old scope.
3. Common Scenarios Where It Happens
- Callbacks for
setInterval
/setTimeout
- Loops with
requestAnimationFrame
- Event handlers from WebSocket or
addEventListener
- Async callbacks (
then
,async/await
) reading state
The common theme: a function registered once keeps living for a long time.
4. How to Fix It
① Specify Dependencies Correctly
The simplest fix is to include state in the dependency array of useEffect
.
useEffect(() => {
const id = setInterval(() => {
console.log("count:", count); // always the latest value
}, 1000);
return () => clearInterval(id);
}, [count]);
But beware: the effect re-subscribes on every change, which may affect performance or resource management.
② Use Functional setState
For state updates, you can use the functional form of setState
, which always receives the latest value regardless of closures.
setCount(prev => prev + 1);
This avoids stale closures and is the safest pattern.
③ Use useRef
(a powerful stale closure workaround)
Here’s where useRef
shines.
5. How useRef
Helps Avoid Stale Closures
What Is useRef?
useRef
creates a box that persists across renders.
const ref = useRef(0);
ref.current = 123;
console.log(ref.current); // 123
- Store values in
ref.current
- Updating it does not trigger re-renders
- Useful not only for DOM refs, but also for persisting variables
Example: Fixing a Stale Closure
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// Mirror latest count into ref
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log("Latest count:", countRef.current); // always up to date
}, 1000);
return () => clearInterval(id);
}, []);
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
- Inside
setInterval
, readcountRef.current
- No more stale closure — always the latest value
Advanced: Storing Functions in useRef
You can also store functions inside a ref to always call the latest logic.
const callbackRef = useRef<(val: number) => void>(() => {});
useEffect(() => {
callbackRef.current = (val: number) => {
console.log("Latest count:", count, "val:", val);
};
}, [count]);
// Example: called from external events
socket.on("message", (val) => {
callbackRef.current(val);
});
6. Summary
- Closures remember the scope from when the function was created
- Stale closures are closures stuck with old scope
- In React, they often show up in intervals, event handlers, async callbacks, etc.
- Solutions:
- Correctly specify dependencies
- Use functional
setState
- Use
useRef
to persist latest values or functions
A stale closure is like a “time-traveling bug in React.”
A function keeps carrying an old scope into the future — and that’s why your state “doesn’t update.”
This content originally appeared on DEV Community and was authored by Learcise