JavaScript Let

5 minute read

let was introduced in ES6 (2015) as a saner replacement for var. It’s block-scoped, can’t be re-declared in the same scope, and has a brief “temporal dead zone” before its declaration that catches a whole class of bugs. Reach for let whenever you have a variable that genuinely needs to change value.

The Basics

let count = 0;
count = count + 1;
console.log(count);   // 1

You declare with let, you can reassign as often as you like — same as var, but with much better scoping rules.

Block Scope — The Big Win

A block is anything wrapped in { }: function bodies, if statements, for loops, even a stand-alone pair of braces. A let declared inside a block is invisible outside it.

if (true) {
  let secret = "shh";
  console.log(secret);   // "shh"
}
console.log(secret);     // ❌ ReferenceError — secret is not defined

Compare with var:

if (true) {
  var leaked = "oops";
}
console.log(leaked);     // "oops"  — leaked out of the block!
              ┌────── if block ──────┐
              │                      │
   let:       │ secret exists here   │   ← gone after }
              │                      │
              └──────────────────────┘
   var:       │ leaked exists here   │ and out here too
              └──────────────────────┘──────────────────▶

Re-declaration Is Forbidden

In the same scope, you can’t declare the same let twice. This catches typos and accidental overwrites.

let user = "Ada";
let user = "Linus";   // ❌ SyntaxError: Identifier 'user' has already been declared

// You can reassign — just not re-declare
let name = "Ada";
name = "Linus";       // ✅ fine

In different scopes, the same name is fine — it’s a new variable:

let user = "Ada";
{
  let user = "Linus";   // ✅ new variable inside this block
  console.log(user);    // "Linus"
}
console.log(user);      // "Ada"

The Temporal Dead Zone (TDZ)

let is hoisted to the top of its block — but you cannot access it until the line where it’s declared. The window between block start and declaration is called the Temporal Dead Zone.

{
  console.log(x);   // ❌ ReferenceError — TDZ
  let x = 5;
  console.log(x);   // 5
}
{
  ╔══════════════════════════╗
  ║  TDZ — accessing x       ║   ← ReferenceError if used here
  ║  throws ReferenceError   ║
  ╚══════════════════════════╝
  let x = 5;   ← TDZ ends here
  // x is usable from this point
}

This is intentional. With var, accessing a not-yet-declared variable silently returned undefined, hiding bugs. With let, you get a loud error.

  var let
Hoisted? ✅ Yes ✅ Yes
Initial value during hoisting undefined Uninitialized (TDZ)
Access before declaration Returns undefined (silent) Throws ReferenceError (loud)

let in Loops — The Classic Fix

The most famous reason let exists: closures inside loops.

// ❌ The classic var loop bug
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3   ← all three closures share ONE i

// ✅ With let, each iteration gets its own i
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2

Why? With let, the engine creates a fresh binding of i for each iteration of the loop. Each closure captures its own copy.

var i:    ┌───┐
          │ i │ ◄── all 3 callbacks point here
          └───┘

let i:    ┌───┐    ┌───┐    ┌───┐
          │ i=0│   │ i=1│   │ i=2│  ◄── each callback has its own
          └───┘    └───┘    └───┘

Tip: This single feature is the reason let won. Loops with callbacks (event listeners, timers, async work) used to require IIFE workarounds. With let, it just works.

let vs const — When to Pick Which

Both are block-scoped, both have TDZ, both forbid re-declaration. The single difference: const forbids reassignment.

const PI = 3.14;
PI = 3.15;        // ❌ TypeError

let radius = 5;
radius = 10;      // ✅ fine

Rule of thumb:

  • Default to const.
  • Use let only when you can point to the line that reassigns it.
Situation Use
API endpoint, config, formula const
Loop counter (for (let i…)) let
Accumulator that grows let
Function parameter (already block-scoped, no keyword)
Object you’ll mutate but not reassign const

Loop Variables: For Counter vs For Each

// Classic counter — needs let (i is reassigned each iteration)
for (let i = 0; i < items.length; i++) {
  console.log(items[i]);
}

// for…of — use const, since each iteration is a NEW binding
for (const item of items) {
  console.log(item);
}

// for…in — same, each iteration is a fresh binding
for (const key of Object.keys(obj)) {
  console.log(key, obj[key]);
}

Both for…of and for…in create a new binding per iteration, so const is correct — you’re not reassigning the existing one.

let at Module / Global Scope

A let at the top level of a module is module-scoped, not added to window:

// module.js
let secret = "hello";
// window.secret === undefined  ✅

Compare with var at script scope, which does leak onto window:

var oldStyle = "hello";
console.log(window.oldStyle);   // "hello"  ← global pollution

A Realistic Example

async function paginatedFetch(url) {
  const results = [];   // const — never reassigned
  let cursor = null;    // let — gets reassigned each page
  let pageNum = 0;      // let — counter

  do {
    const params = cursor ? `?cursor=${cursor}` : "";
    const res = await fetch(`${url}${params}`);
    const { items, next } = await res.json();

    results.push(...items);
    cursor = next;
    pageNum += 1;

    console.log(`Fetched page ${pageNum}, ${items.length} items`);
  } while (cursor);

  return results;
}

Pattern: const for things that don’t change, let for the cursor and counter that genuinely evolve.

let vs var — Side by Side

Feature var let
Scope Function Block
Re-declarable in same scope ✅ ❌
Hoisting behaviour undefined TDZ → ReferenceError
Adds to window at top level ✅ ❌
Iteration captures fresh binding ❌ ✅
Recommended? ❌ Never ✅ Yes

Best Practices Checklist

  • ✅ Use let when (and only when) you reassign
  • ✅ Declare let as close to its first use as possible
  • ✅ Always use let in for counters (for (let i = 0; …))
  • ✅ Trust your linter: prefer-const will tell you when let could be const
  • ❌ Don’t declare a let at the top of a function “in case you need it later”
  • ❌ Don’t shadow outer lets on purpose — it confuses readers
  • ❌ Don’t use var. Ever. There is no scenario in modern code where var is the right answer.

Warning: Mixing var and let in the same file looks innocent but produces subtle bugs around hoisting. Pick let/const and remove every var.

What’s Next?

let is for values that change. const is for values that don’t — and it’s the keyword you’ll use 90% of the time. Let’s see how it works, what it actually locks, and how to make objects truly immutable.

← Previous: JS Variables Next: JS Const →

Updated: