Have You Ever Experienced This When Using React?
- You call
setState, but inside an event handler the value is still old - Inside a
setInterval, readingstatealways 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,countremains 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
useRefto 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.”
Top comments (0)