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, and const, 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 const does 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.

const name = "Ada";
let age = 36;

age = 37; // fine, let allows reassignment
console.log(name, age);

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:

function demo() {
  if (true) {
    var functionScoped = "declared with var";
    let blockScoped = "declared with let";
    console.log(blockScoped);
  }

  // Still visible: var leaked out of the block.
  console.log(functionScoped);

  try {
    console.log(blockScoped);
  } catch (error) {
    console.log(error.constructor.name + ":", error.message);
  }
}

demo();

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:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log("let:", i), 0);
}

for (var j = 0; j < 3; j++) {
  setTimeout(() => console.log("var:", j), 0);
}

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.

const user = { name: "Ada" };
const scores = [1, 2, 3];

// Both allowed: the bindings are const, the values are not frozen.
user.name = "Grace";
scores.push(4);

console.log(user);
console.log(scores);

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 let by default. Start with const and downgrade only when you actually reassign.
  • Assuming const freezes an object. It does not. Use a copy, or Object.freeze if you truly need it locked.
  • Using var in 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.

function makeGreeters() {
  const greeters = [];

  for (var i = 0; i < 3; i++) {
    greeters.push(() => console.log("Hello, " + i));
  }

  return greeters;
}

makeGreeters().forEach((greet) => greet());