Articles › JavaScript
JavaScript Scope, Hoisting and Closures Explained Clearly
Understand how JavaScript really resolves variables: global, function and block scope, var vs let vs const, hoisting, the temporal dead zone and closures with real examples.
Scope, hoisting and closures are the foundation of how JavaScript works. They explain why a variable is undefined instead of throwing an error, why a loop logs the same number five times, and how React hooks remember values between renders. Once these three ideas click, a whole category of bugs simply disappears.
Scope: where a variable is visible
Scope is the region of code where a variable can be accessed. JavaScript has three kinds:
- Global scope — declared outside any function or block.
- Function scope — declared inside a function, visible everywhere in that function.
- Block scope — declared with
letorconstinside{ }(if, for, while, plain blocks).
const app = "Portfolio"; // global
function greet() {
const greeting = "Hello"; // function scope
if (true) {
const name = "Ali"; // block scope
console.log(`${greeting}, ${name} from ${app}`);
}
// console.log(name); ❌ ReferenceError: name is not defined
}
Lexical scope and the scope chain
JavaScript uses lexical (static) scope: what a function can see is determined by where it is written, not where it is called. When you read a variable, the engine looks in the current scope, then the enclosing one, and so on up to the global scope. This lookup path is the scope chain.
const level = "global";
function outer() {
const level = "outer";
function inner() {
console.log(level); // "outer" — found in the nearest enclosing scope
}
return inner;
}
outer()(); // "outer"
var vs let vs const
var |
let |
const |
|
|---|---|---|---|
| Scope | function | block | block |
| Hoisted | yes, initialized to undefined |
yes, but in TDZ | yes, but in TDZ |
| Re-declare in same scope | allowed | error | error |
| Re-assign | allowed | allowed | not allowed |
Tip: Use
constby default andletwhen you really need to re-assign. Avoidvarin modern code.
Note that const prevents re-assignment, not mutation:
const user = { name: "Ali" };
user.name = "Sara"; // ✅ allowed — the object is mutated
// user = {}; ❌ TypeError — the binding can't change
Hoisting
Before executing code, the JavaScript engine scans each scope and registers its declarations. This is called hoisting — it behaves as if declarations were moved to the top.
console.log(a); // undefined — var is hoisted and initialized to undefined
var a = 5;
sayHi(); // "Hi!" — function declarations are fully hoisted
function sayHi() {
console.log("Hi!");
}
Function expressions and arrow functions assigned to variables follow the variable's rules:
greet(); // ❌ TypeError (var) or ReferenceError (let/const)
const greet = () => console.log("Hello");
The temporal dead zone (TDZ)
let and const are hoisted too, but they're not initialized. Accessing them before the declaration line throws:
console.log(total); // ❌ ReferenceError: Cannot access 'total' before initialization
let total = 10;
The period between the start of the scope and the declaration is the temporal dead zone. It's a feature: it turns silent undefined bugs into loud errors.
Closures
A closure is a function together with the variables from the scope in which it was created. The inner function keeps access to those variables even after the outer function has returned.
function createCounter() {
let count = 0; // private state
return {
increment: () => ++count,
decrement: () => --count,
get value() { return count; },
};
}
const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value); // 2
// count is not accessible from outside — it's truly private
Each call to createCounter creates a new, independent count. That's how JavaScript implements private state without classes.
Closures you use every day
// 1) Event handlers
function setupButton(button, message) {
button.addEventListener("click", () => alert(message)); // remembers message
}
// 2) Function factories
const multiply = (factor) => (n) => n * factor;
const double = multiply(2);
double(21); // 42
// 3) Debounce
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const search = debounce((q) => console.log("searching", q), 300);
React hooks rely heavily on closures: every render creates new functions that "close over" that render's props and state.
The classic loop bug
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 3, 3, 3
With var, there's one i shared by the whole function. By the time the callbacks run, the loop has finished and i is 3.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// 0, 1, 2
With let, each iteration gets a new binding, so each closure captures its own i.
Stale closures in React
The same idea causes the famous "stale state" bug:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1); // ❌ always reads 0 from the first render
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds}</p>;
}
The interval callback closed over seconds from the first render. Fix it with the updater form, which always receives the latest value:
setSeconds((s) => s + 1); // ✅
Closures and memory
A closure keeps its variables alive as long as the function itself is reachable. Usually that's exactly what you want, but long-lived closures (global event listeners, intervals, caches) can keep large objects in memory. Remove listeners and clear timers when they're no longer needed.
Summary
- Scope decides where variables are visible; JavaScript resolves them lexically, up the scope chain.
- Prefer
constandlet; they're block-scoped and protected by the temporal dead zone. - Hoisting registers declarations before execution:
varbecomesundefined, functions are fully available,let/conststay uninitialized. - Closures let functions remember the variables of the scope they were created in — the basis of private state, factories, callbacks and hooks.
FAQ
Are closures created only by nested functions?
Every function in JavaScript forms a closure over its surrounding scope. The effect is just most visible when an inner function outlives its outer function.
Is hoisting a real "move" of code?
No. The engine processes declarations in a creation phase before running the code. "Moving to the top" is a mental model, not what actually happens.
Why do interviewers love these topics?
Because they reveal whether someone understands how JavaScript executes, rather than just its syntax — and they explain many real-world bugs.