JavaScript variables, scope, and hoisting
var, let, and const look interchangeable until they aren't. The differences in
scope, hoisting, and reassignment explain a long list of behaviors: why a
variable is undefined before its line, why a let throws if accessed too early, and
why the classic var loop prints the wrong numbers. This guide ties it all together.
var, let, and const
| Keyword | Scope | Hoisting | Reassignable | Redeclarable |
|---|---|---|---|---|
var | function | yes, init undefined | yes | yes |
let | block | yes, in TDZ | yes | no |
const | block | yes, in TDZ | no | no |
The subtle one is const: it makes the binding constant, not the value. You can't
reassign the variable, but you can mutate the object or array it points to.
const user = { name: 'Ada' }
user.name = 'Grace' // mutating the object
user = {} // TypeError — reassigning the binding
const list = [1, 2]
list.push(3) // [1, 2, 3]
Modern guidance: default to const, use let only when you must reassign, and
avoid var.
Scope: function vs block
Function scope (var) makes a variable visible anywhere in the enclosing function,
regardless of nested blocks. Block scope (let/const) confines it to the nearest
{ } — including if, for, and bare blocks.
function demo() {
if (true) {
var v = 'function-scoped'
let l = 'block-scoped'
}
console.log(v) // 'function-scoped' — var leaks out of the if
console.log(l) // ReferenceError — l only exists inside the if
}
This leakage is a primary reason to avoid var: block scoping keeps variables where
they're relevant.
Lexical scope and the scope chain
JavaScript uses lexical (static) scope — a variable's accessibility is determined by
where it's written, not where it's called. Variable lookup walks the scope chain
outward (current -> enclosing -> … -> global), and the first match wins; no match is a
ReferenceError.
const a = 'global'
function outer() {
const b = 'outer'
function inner() { return `${a} ${b}` } // reaches outward
return inner()
}
Inner scopes see outer variables, never the reverse. (This is exactly what closures
preserve.) Shadowing — declaring a same-named variable in an inner scope — is legal
and creates a separate binding; redeclaring in the same scope with let/const is a
SyntaxError.
Hoisting
Hoisting is the engine processing declarations before executing code, so they behave as if moved to the top of their scope. What gets hoisted differs:
var— hoisted and initialized toundefined(readable, just empty).- Function declarations — hoisted entirely (callable before their line).
let/const— hoisted but uninitialized (the temporal dead zone).
console.log(a) // undefined (var hoisted, value not yet assigned)
var a = 1
greet() // 'hi' (function declaration fully hoisted)
function greet() { return 'hi' }
console.log(b) // ReferenceError (TDZ)
let b = 2
Mental model: it's two phases. A creation phase registers all declarations
(var->undefined, let/const->uninitialized, functions->fully defined); an
execution phase runs the code and performs assignments in place. Nothing physically
moves — only assignments happen where you wrote them. Function expressions (and
arrows) follow normal variable hoisting, so they're not callable before assignment.
The temporal dead zone
The TDZ is the window between entering a scope and the line where a let/const is
declared. The binding exists but is uninitialized, so any access throws a
ReferenceError — even typeof.
{
console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x = 5
}
It exists on purpose: it turns "used before declared" from a silent undefined bug (the
var behavior) into a loud, immediate error. Class declarations are also hoisted into a
TDZ, so they can't be used before their line either.
The loop closure bug
The most famous scope question. With var, the loop reuses one function-scoped
binding, so deferred callbacks all read its final value. let creates a fresh binding
per iteration.
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)) // 0 1 2
By the time the timers fire, the var loop has finished and i is 3. let re-binds
per iteration. The pre-ES6 fix was an IIFE to manufacture a new scope each pass.
Strict mode, modules, and globals
At the top level of a classic script, var creates a property on the global object
(window.x); let/const do not. Assigning without declaring (x = 5) in sloppy
mode creates an accidental global — strict mode throws a ReferenceError instead.
'use strict'
function f() { count = 1 } // ReferenceError (no var/let/const)
ES modules have their own top-level scope and are always strict, so even top-level
var doesn't pollute the global object — a big improvement over scripts. Minimize
globals: they create hidden coupling and name collisions. When you truly need one,
namespace it under a single object.
Common pitfalls
switchandlet: aswitchis one block scope, so aletin onecaseis in the TDZ for the whole switch — wrap each case body in{ }.constrequires initialization (const x;is aSyntaxError).constarrays/objects are still mutable — freeze withObject.freeze(shallow) if you need immutable contents.
Recap
Choose variables by scope and mutability: const by default, let when you
reassign, never var. Block scope keeps variables contained; lexical scope and
the scope chain determine what's visible. Hoisting registers declarations before
execution — var becomes undefined, functions become callable, and let/const sit
in the temporal dead zone until declared, which catches use-before-declaration bugs.
Understand binding-per-iteration and the loop bug disappears; prefer modules and strict
mode and accidental globals disappear too.