Handbooks / JavaScript / Chapter 2
Scope and Closures
7 pages · ~5 min✓ Reviewed
Builds on Types and Coercion. Next up: this and Prototypes.
Variables and Scope Mental Model
1. var, let, const
| var | let | const | |
|---|---|---|---|
| Scope | function | block | block |
| Reassign | yes | yes | no |
- A block is any
{ }:if,for, or a bare block constlocks the binding, not the value
javascript
const a = []; a.push(1); // works a = []; // TypeError
mutating is fine, rebinding throws
2. Lexical scope and lookup
- Lexical = visibility fixed by where code is written
- Not by where the function is called
- Current scope
- Enclosing scopesoutward
- Global
- ReferenceErrornot found
3. Global object
| Global declaration | On globalThis? |
|---|---|
var | yes |
| function declaration | yes |
let / const | no |
Remember
Top-level let x = 1 gives globalThis.x as undefined; only var and functions attach.
Hoisting and the Temporal Dead Zone
1. What hoisting does
- Hoisting: declarations are registered at scope creation, before code runs
- The name exists from the top of its scope
varis hoisted and initialised to undefined
javascript
console.log(x); var x = 5;
no error: x exists, value undefined
output
undefined
2. let, const and functions
let/constare hoisted but uninitialised- Access before the declaration line → ReferenceError
- That gap is the TDZ (temporal dead zone)
| Used before its line | Result |
|---|---|
var x | undefined |
let / const | ReferenceError |
| function declaration | works, body hoisted |
var f = function(){} | TypeError: not a function |
typeof on TDZ variable | ReferenceError |
typeof on undeclared name | 'undefined' |
3. Interview Point
- Does let hoist? Yes, but it stays in the TDZ until its line runs
- Why TypeError on an early call?
var fis undefined, not a function - typeof on a TDZ variable? Throws; an undeclared name gives 'undefined'
Closures: How They Work and Where to Use Them
1. What a closure is
- Closure = function + reference to the scope where it was created
- Keeps that scope alive after the outer function returns
- Captures by reference, not value: later changes are seen
2. Private state and factories
nis private: only the returned function can touch it- Factory: outer arg fixed now, inner arg later (partial application)
javascript
function counter(){ let n = 0; return () => ++n } const c = counter(); console.log(c(), c()); function mul(a){ return b => a*b } const double = mul(2); console.log(double(5), c.n);
output
1 2 10 undefined
3. Common uses
| Use | Closure remembers |
|---|---|
| Callbacks, event handlers | the data they need |
| Memoisation | the cache object |
once / debounce | a called flag / timer id |
Shared, not copied
The inner function reads the live variable. Change it outside and the closure sees the new value
The Loop Closure Pitfall
1. The Bug
- All callbacks share one function-scoped
i - Callbacks run later, after the loop ends, when
iis 3
javascript
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)) }
output
3 3 3
2. Three Fixes
| Fix | How | Prints |
|---|---|---|
let in loop head | fresh binding copy per iteration | 0 1 2 |
| IIFE (legacy) | (function(j){ setTimeout(()=>console.log(j)) })(i) | 0 1 2 |
forEach / map | callback parameter is a new scope per element | 0 1 2 |
3. Same Trap: Click Handlers
- Handlers attached in a
varloop all read the final index - Fix the same way:
letor an IIFE
Remember
var = one shared binding; let in the loop head = one binding per iteration
IIFE and Modules
1. IIFE and module pattern
(function(){ ... })();runs once, private scope- Wrapping
( )make it an expression, not a declaration - Module pattern: IIFE returns public methods; vars stay private via closures
javascript
const counter = (function () { let n = 0; return { inc: () => ++n }; })(); counter.inc(); // n is unreachable
2. ES modules
- Own module scope: top-level vars are not global
- Strict mode by default
- Loaded once, then cached
- Exports are live bindings: importers see new values of an exported
let - Importers cannot assign to an import
javascript
// a.js export let count = 0; export function inc() { count++; } // b.js import { count, inc } from './a.js'; inc(); // count is now 1 count = 5; // TypeError
3. What to use
| Use | When |
|---|---|
| IIFE | old script-tag code |
| ES modules | new code |
const | default |
let | when reassigning |
var | never |
Scope and Closures Cheat Sheet
1. Declarations
| var | let | const | |
|---|---|---|---|
| Scope | function | block | block |
| Hoisted as | undefined | TDZ | TDZ |
| Redeclare | yes | no | no |
| Reassign | yes | yes | no |
- const first
letonly if reassigned- avoid
var
2. Hoisting
| Form | Used before its line |
|---|---|
var | undefined |
let / const | ReferenceError (TDZ) |
| function declaration | works, fully hoisted |
| function expression | TypeError, only the variable is hoisted |
3. Closures and the Loop Fix
- Inner function keeps a reference to outer variables
- Each call of the outer function makes a new independent scope
letin the loop head gives per-iteration bindingvarneeds an IIFE:(function(j){ ... })(i)
javascript
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); }
output
0 1 2
Classic bug
var in the loop head shares one i, so the callbacks print 3 3 3
Check yourself
What does this print?
- 3 3 3
- One function-scoped
iis shared and is 3 when the callbacks run - With
let i, each iteration gets its own binding: 0 1 2
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i))
Why does this throw?
- ReferenceError
yis in the temporal dead zone until its declaration linetypeofis only safe on names never declared
console.log(typeof y) let y = 1
Which line throws, and what error?
- Only the last line: TypeError
pushmutates the value, and the binding stays the samea = []reassigns the binding, whichconstforbids
const a = [] a.push(1) a = []
What does this print?
- 2
- A closure captures the variable by reference, not its value at creation
let n = 1 const f = () => n n = 2 console.log(f())