Closures & Lexical Scope — Why Stale Closures Break React
Introduction
When working with React Hooks, sometimes you may see strange bugs:
- State does not update correctly
- A function prints an old value
useEffectbehaves unexpectedly
Most of the time, this happens because of stale closures.
To understand this problem, we need to understand two basic JavaScript ideas:
- Lexical Scope
- Closures
These are core JavaScript fundamentals, but they become very important in React.
1. What is Lexical Scope?
Lexical scope means:
A function can access variables from the place where it was created.
JavaScript decides variable access based on where the code is written.
Example
function outer() {
const message = "Hello from outer function";
function inner() {
console.log(message);
}
inner();
}
outer();
Why this works
inner()is created insideouter()- Because of lexical scope, it can access
message
Even though message is not inside inner(), JavaScript still allows it.
2. What is a Closure?
A closure happens when a function remembers variables from its outer scope, even after the outer function finishes.
Example
function createCounter() {
let count = 0;
return function () {
count++;
console.log(count);
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
Why this works
The returned function remembers the variable count.
Even though createCounter() finished running, the function still has access to it.
Closures are used in many places:
- React
- Event handlers
- Timers
- Memoization
Closures are powerful, but they can also cause bugs.
3. What is a Stale Closure?
A stale closure happens when a function captures an old value of a variable.
The variable changes later, but the function still uses the old value.
4. How This Breaks React
React components re-render many times.
Each render creates new variables and functions.
If a function captures values from an old render, it may use outdated state.
Example
import { useState, useEffect } from "react";
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Expected output
0
1
2
3
Actual output
0
0
0
0
Why this happens
useEffectruns only once- The closure captures the initial value of
count - The interval keeps using that old value
This is called a stale closure.
5. Fixing Stale Closures
1. Add dependencies
useEffect(() => {
const interval = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(interval);
}, [count]);
Now React recreates the effect when count changes.
2. Use functional updates
setCount(prev => prev + 1);
This always uses the latest state.
3. Use useRef
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
Refs help store values that stay updated across renders.
6. Where Stale Closures Commonly Appear
You will usually see stale closures in:
setIntervalandsetTimeout- Event listeners
useEffectuseCallback- Async functions
7. Simple Mental Model
A good way to think about React:
Every render is like a snapshot in time.
Functions created during that render remember the state from that snapshot.
If they run later, they may still use that old snapshot.
Conclusion
Closures and lexical scope are basic JavaScript concepts, but they can cause confusing bugs in React.
Understanding them helps you:
- Avoid stale closure bugs
- Write better React code
- Debug state problems faster
Once this concept clicks, many React bugs suddenly become much easier to understand.
React is not broken — it's just JavaScript closures working as designed.
Happy coding! 🚀