let, const, and Scope
Block scope, the temporal dead zone, and why var still causes surprises.
What you'll learn
- The difference between
var,let, andconst, and which to reach for - What block scope actually means, and how it changes loops
- Why a variable can exist but still throw when you read it
- What
constdoes and does not protect
Three ways to declare a variable
Modern JavaScript has three. In practice you only need two:
const— a binding that cannot be reassigned. Use this by default.let— a binding you intend to reassign. Use it when you must.var— the original. Avoid it in new code, for the reasons below.
Starting with const is a useful habit: it makes reassignment a deliberate
choice rather than an accident, and it tells the next reader that this name
points at the same thing for its whole life.
Reassigning a const is an error. It is worth knowing that this one is caught
before your code ever runs — the engine rejects the program rather than
throwing partway through it:
const name = "Ada";
name = "Grace";
// TypeError: Assignment to constant variable.
Block scope
let and const are scoped to the nearest block — any pair of braces. var
ignores blocks entirely and attaches itself to the enclosing function.
That difference is easiest to see in an if statement:
Why this matters in loops
This is the classic one. A var loop counter is a single binding shared by
every iteration, so by the time a deferred callback runs, it sees the final
value. With let, each iteration gets its own binding.
Change let to var in the loop below and run it again — the output goes from
0 1 2 to 3 3 3:
You will meet this again in React, where a stale captured value inside a callback is one of the more common sources of confusion.
The temporal dead zone
A let or const binding exists from the moment its block starts, but you
cannot read it until the declaration runs. That gap is called the temporal
dead zone, and touching the variable inside it throws a ReferenceError.
var behaves differently: it is initialised to undefined up front, so
reading it early gives you a confusing undefined rather than an error.
console.log(hoisted); // undefined — var is initialised early
console.log(notYet); // ReferenceError: Cannot access 'notYet' before initialization
var hoisted = "declared with var";
let notYet = "declared with let";
The error is the better behaviour. It tells you exactly where the problem is,
instead of letting an undefined flow onward and fail somewhere else.
This example is deliberately not runnable here. The editors on this page
transpile your code before running it, and that step rewrites let to var —
which erases the very dead zone we are describing. Try it in your browser's own
console to see the real error.
const is not immutability
const protects the binding, not the value. You cannot point the name at a
different object, but you can freely change the object it points at.
What you cannot do is point user at a different object — user = { ... } is
the same TypeError as before.
This matters more than it first appears. React compares state by identity, so
mutating an object in place — exactly what const still permits — is a common
reason a component fails to re-render. You will see the fix, copying instead of
mutating, in Destructuring, Spread, and Rest.
Common mistakes
- Reaching for
letby default. Start withconstand downgrade only when you actually reassign. - Assuming
constfreezes an object. It does not. Use a copy, orObject.freezeif you truly need it locked. - Using
varin a loop with async work. Every iteration shares one binding, so callbacks all see the last value.
Exercise
The function below is meant to collect three greeting functions and call them,
printing Hello, 0, Hello, 1, Hello, 2. It prints Hello, 3 three times.
Fix it with a one-word change, then confirm it in the console.