JavaScript Closures Explained with Practical React Examples
What closures really are, why var in a loop prints 3, 3, 3, and how to fix stale state in React with functional updates and refs.
Closures are often introduced with a sentence that is technically correct and practically unhelpful: "a closure is a function bundled with its lexical environment."
Let's make it useful instead. By the end you'll understand closures, and more importantly, you'll be able to fix the most confusing React bug they cause.
The simple idea: a backpack
When a function is created, it packs a backpack with every variable it can see at that moment. Wherever the function goes later, the backpack goes with it.
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const next = makeCounter();
console.log(next(), next(), next()); // 1 2 3makeCounter finished running after the first line. Normally its count variable would be gone. But the inner function packed count in its backpack, so it keeps it alive and keeps changing it.
Each call to makeCounter packs a fresh backpack:
const other = makeCounter();
console.log(other()); // 1, its own count, not 4That's a closure: a function plus the variables it carries. Nothing more mysterious.
The classic trap: var in a loop
Interviewers love this one:
const fns = [];
for (var i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map((f) => f())); // [3, 3, 3]Why not [0, 1, 2]? Because var creates one i for the whole function. All three arrow functions pack a reference to that same i, and by the time they run, the loop has finished and i is 3.
The backpack holds a reference to the variable, not a photo of its value.
Switch to let:
const fns = [];
for (let i = 0; i < 3; i++) fns.push(() => i);
console.log(fns.map((f) => f())); // [0, 1, 2]let creates a new i for each iteration, so each function packs a different variable.
Closures you use every day
Debounce
A search box shouldn't hit the server on every keystroke. Debouncing waits until typing pauses:
function debounce(fn, wait) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), wait);
};
}
const search = debounce((q) => console.log("search:", q), 300);
search("p"); search("pa"); search("pan"); search("pandas");
// only logs: search: pandastimer lives in the closure, shared by every call, so each keystroke can cancel the previous timer. Without a closure, you'd need a global variable.
Event handlers in React
Every handler you write in a component is a closure:
function Greeting({ name }) {
const handleClick = () => alert(`Hello, ${name}`);
return <button onClick={handleClick}>Say hi</button>;
}handleClick packs name from this render. Usually that's exactly what you want. Sometimes it isn't.
The React bug: stale closures
Here's a timer that should count up every second:
import { useEffect, useState } from "react";
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // 🐛
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}It shows 1, then gets stuck at 1 forever.
Here's why. The effect runs once, on the first render, because the dependency array is empty. The interval callback packs count from that first render, where count is 0. Every second it runs setCount(0 + 1). React re-renders with 1, but the interval still carries the old backpack where count is 0.
It's like a football manager giving instructions from a printout of the first-half lineup. The team on the pitch changed, but the instructions still say the old names.
Fix 1: functional update (best here)
Don't read count at all. Ask React for the latest value:
setCount((c) => c + 1);React passes the current state into your function, so the closure no longer needs to carry count. This is the cleanest fix whenever the new state depends on the old one.
Fix 2: list the dependency
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]);Now the effect re-runs whenever count changes, creating a fresh closure each time. It works, but it tears down and rebuilds the interval every second. Fine for this example, wasteful for heavier work like a WebSocket connection.
The react-hooks/exhaustive-deps lint rule catches the original bug: it would warn that count is missing from the array. Keep it switched on.
Fix 3: a ref for "read the latest, don't restart"
Sometimes you need the latest value inside a long-lived callback without restarting the effect, for example sending the current score to analytics every 10 seconds:
import { useEffect, useRef, useState } from "react";
function Game() {
const [score, setScore] = useState(0);
const scoreRef = useRef(score);
scoreRef.current = score; // always up to date after each render
useEffect(() => {
const id = setInterval(() => {
sendToAnalytics(scoreRef.current);
}, 10_000);
return () => clearInterval(id);
}, []);
return <button onClick={() => setScore((s) => s + 1)}>{score}</button>;
}The closure carries the ref object, which never changes, and the ref's .current always points at the newest score. The interval is created once and still reads fresh data.
Which fix when?
| Situation | Fix |
|---|---|
| New state depends on old state | Functional update: setX(x => ...) |
| Effect should restart when a value changes | Add it to the dependency array |
| Long-lived callback needs the latest value, no restart | Store it in a ref |
What goes wrong
- Silencing the exhaustive-deps warning. It's almost always pointing at a real stale closure. Fix the code, don't mute the rule.
- Putting objects or functions in dependencies. They're recreated each render, so the effect runs every time. Move them inside the effect or memoise them with
useMemo/useCallback. - Assuming the closure sees "the variable". In React, each render has its own
countconstant. A closure sees the one from the render it was born in.
For more on effects, read useEffect dependencies, cleanup and infinite loops. For forms, see controlled vs uncontrolled components.
In one line: a closure is a function carrying the variables it saw when it was created, and in React that means callbacks see the state of their own render, so use functional updates, correct dependencies or refs to read fresh values.
Keep reading
New games, tools and tutorials
About one short email a week. No spam, one-click unsubscribe.