JavaScript Advanced #1 Closures and Scope
The JavaScript Advanced series begins. Once you can use the tools from Basics/Intermediate freely, it’s time to look at why those tools behave the way they do.
A 7-post series.
- #1 Closures and Scope ← this post
- #2 this binding and call patterns
- #3 Prototype chain
- #4 Event loop and tasks
- #5 Memory model and GC
- #6 Symbol, WeakRef, Proxy
- #7 Module system in depth
This post is about one of JavaScript’s most important concepts — closures.
What is scope — where a variable is visible #
Let’s first nail down the meaning of scope. Scope dictates where a variable is visible.
{
const a = 10;
console.log(a); // 10
}
console.log(a); // ✗ ReferenceError
A variable declared inside { and } is visible only within. The let/const block scope from Basics #2.
Function scope + block scope #
JavaScript has two scopes.
- Function scope — followed by
varandfunctiondeclarations. Function-level boundary. - Block scope — followed by
let/const.{...}boundary.
New code uses let/const, so you mostly only think about block scope.
Lexical scope — decided by where it’s written #
JavaScript follows lexical scope. Where a variable is visible depends on where it’s written, not how the function was called.
const message = 'outer';
function inner() {
console.log(message); // 'outer' — variable visible from its location
}
function outer() {
const message = 'inner';
inner(); // result is the same wherever called
}
outer(); // 'outer'
Even though inner is called inside outer, inner’s message refers to the outer variable visible at its written location. The defining location, not the call location, is the basis.
Closure — a function carries its scope #
The counter example we briefly touched on in Basics #4.
function createCounter() {
let count = 0;
return function() {
count = count + 1;
return count;
};
}
const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
Even though createCounter finished, the inner count lives on. The returned function carries the environment (scope) it was created in. That’s a closure.
The precise definition #
A closure is the combination of a function and the lexical environment in which it was created.
It’s the name for not just the function, but also the outer variables that function references. Every JavaScript function is in fact a closure — it’s just that those without outer references mean less.
What closures produce — private state #
function createCounter() {
let count = 0;
return {
increment() { count++; return count; },
decrement() { count--; return count; },
get value() { return count; },
};
}
const a = createCounter();
const b = createCounter();
a.increment(); // 1
a.increment(); // 2
b.increment(); // 1 — independent of a
console.log(a.value); // 2
console.log(b.value); // 1
a.count; // undefined — externally inaccessible
Each call creates a new closure, each with its own count. You can’t reach a.count from outside — the module pattern’s private state is built this way.
Before ES2022’s #field (Intermediate #1), JavaScript privacy was almost always done with closures.
Callbacks and closures — the most common case #
Async callbacks and event handlers are essentially closures applied.
function attachHandlers(buttons) {
buttons.forEach((btn, i) => {
btn.addEventListener('click', () => {
console.log(`button ${i} clicked`);
});
});
}Each handler remembers its own i. While the function is alive, that i lives with it.
Old pitfall — var and closures
#
Pre-ES2015 JavaScript’s most famous trap.
function attachOld(buttons) {
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(`button ${i}`);
});
}
}This code prints the last i for every button. Since var is function-scoped, every callback shared the same i.
Switching to let fixes it.
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(`button ${i}`);
});
}let is block-scoped, so a fresh i is created each iteration and each callback captures a different i. One of the big reasons let/const were introduced.
Patterns built with closures #
1) Partial Application #
function add(a, b) {
return a + b;
}
function partial(fn, ...preset) {
return function(...rest) {
return fn(...preset, ...rest);
};
}
const add5 = partial(add, 5);
add5(3); // 8
add5(10); // 15
The closure remembers preset. A common tool in functional programming.
2) Memoization #
function memoize(fn) {
const cache = new Map();
return function(arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
const slowSquare = (n) => {
console.log(`computing: ${n}`);
return n * n;
};
const fastSquare = memoize(slowSquare);
fastSquare(5); // computing: 5 → 25
fastSquare(5); // 25 (cached)
fastSquare(3); // computing: 3 → 9
The cache Map survives via closure. React’s useMemo, lodash’s memoize — same idea.
3) Debounce / throttle #
function debounce(fn, delay) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
const handleSearch = debounce((query) => {
fetch(`/api/search?q=${query}`);
}, 300);
input.addEventListener('input', (e) => handleSearch(e.target.value));The timer variable lives between calls so the previous timer can be canceled. Once input pauses for 300ms, the actual call goes out.
Pitfall — closures that live too long #
When a closure captures an outer variable, that variable doesn’t get GC’d as long as the function lives.
function setupHeavy() {
const huge = new Array(1_000_000).fill(0); // big array
return function() {
console.log('hi');
};
}
const fn = setupHeavy(); // huge survives via fn (even unused)
The function above doesn’t use huge — but the closure captures it and it stays in memory. Modern JavaScript engines try not to capture unused variables, but to deliberately release a large object early, set it to null yourself.
function setupHeavy() {
let huge = new Array(1_000_000).fill(0);
process(huge);
huge = null; // release once no longer needed
return function() { console.log('hi'); };
}We dig deeper in #5 Memory Model.
IIFE revisited — old module pattern #
We saw IIFE briefly in Basics #4. From a closure perspective, this was private scope isolation in the era without modules.
const counter = (function() {
let count = 0;
return {
increment() { return ++count; },
decrement() { return --count; },
get value() { return count; },
};
})();
counter.increment(); // 1
counter.count; // undefined — invisible
Rarely used since ES Modules — but you’ll see it in old library code.
TDZ (Temporal Dead Zone) #
An interesting let/const behavior.
console.log(x); // ✗ ReferenceError
let x = 10;Accessing before the declaration line errors. With var you’d get undefined; with let it throws.
This is the Temporal Dead Zone. Before the declaration line, the variable is “exists but unusable.” Prevents bugs caused by accessing a variable before it is declared.
Wrap-up #
What we covered:
- Scope — the visibility range of a variable (block / function scope)
- JavaScript uses lexical scope — based on the defining location
- Closure = function + its lexical environment
- Private state, callbacks, partial application, memoization, debounce — all closures
- The
vartrap — solved bylet - Closures keep captured variables alive — release big objects explicitly
- TDZ —
let/consterrors on pre-declaration access
In the next post (#2 this Binding and Call Patterns) we cover another confusing JavaScript concept — how this is decided by call style, the meaning of call/apply/bind, and arrow function differences.