JavaScriptPublished 7/29/2023

var vs let vs const: Which One Should You Actually Use?

Breaking down how var, let, and const really differ — scope, hoisting, the temporal dead zone — and building a practical rule of thumb for which one to reach for.

Why Do We Even Have Three Ways to Declare a Variable?

JavaScript started out with just var. After years of real-world use, people kept running into behavior that didn't match their intuition — you'd write something that looked perfectly reasonable and still get bitten. So ES6 (2015) introduced let and const, specifically to fix the parts of var's design that hadn't been thought through carefully enough.

So instead of treating these as three parallel options, it helps to think of it this way: let and const are the fix, var is the legacy behavior. Understanding what's wrong with var is really understanding why let and const were designed the way they were.

Scope Works Differently

The first key difference is scope.

var is function-scoped: only a function boundary limits its visibility. Blocks like if, for, or a bare {} don't contain it at all.

js
function checkVar() {
  if (true) {
    var message = 'hello'
  }
  console.log(message) // hello — it leaked right out of the if block
}

let and const are block-scoped: once a {} wraps around them, they're unreachable outside that block.

js
function checkLet() {
  if (true) {
    let message = 'hello'
  }
  console.log(message) // ReferenceError: message is not defined
}

This difference shows up most clearly in a for loop. Take a guess — what does this log?

js
for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 0)
}
// logs: 4 → 4 → 4

That's because var i is a single variable shared across the whole function. By the time the loop finishes, i has already become 4, so by the time each setTimeout callback actually runs, they're all reading that same 4.

What if you swap var for let?

js
for (let i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, 0)
}
// logs: 1 → 2 → 3

The clever part about let is that every iteration of the loop creates a brand-new i, scoped to that iteration's block. Each of the three callbacks closes over a different i, which is exactly why you get the order you'd intuitively expect.

Hoisting and the Temporal Dead Zone

For more detail, see the companion post on Hoisting

All three declaration types get hoisted — meaning the declaration itself is registered at the top of its scope — but what state they end up in afterward is completely different.

A hoisted var gets initialized to undefined right away, so reading it before the declaration line doesn't throw — you just get undefined:

js
console.log(a) // undefined
var a = 10

let and const get hoisted too, but they land in a "declared but not yet initialized" state — that stretch is called the Temporal Dead Zone (TDZ). Accessing a variable while it's in the TDZ throws immediately:

js
console.log(b) // ReferenceError: Cannot access 'b' before initialization
let b = 10

This is actually a good thing. var's "silently reads as undefined" behavior tends to bury bugs deeper — by the time something visibly breaks, you're often several lines removed from the actual cause. The TDZ surfaces the error the moment it happens, forcing you to put the declaration before its use.

Can You Redeclare It? Can You Reassign It?

var lets you redeclare the same variable name in the same scope with zero warnings:

js
var x = 1
var x = 2 // totally legal, no error

Neither let nor const allows redeclaring a name in the same scope:

js
let y = 1
let y = 2 // SyntaxError: Identifier 'y' has already been declared

As for reassignment: let allows it, const doesn't:

js
let count = 1
count = 2 // fine
 
const max = 100
max = 200 // TypeError: Assignment to constant variable.

Here's something a lot of people get wrong: what const locks down is the binding between the variable and its value, not the value's contents. If a const holds an object or array, you can still mutate what's inside it:

js
const user = { name: 'Alice' }
user.name = 'Bob' // fine — mutating the object's contents
user.age = 20 // fine — adding a new property works too
 
user = {} // TypeError — this is what's actually forbidden: swapping out the bound object entirely

If you genuinely want the object's contents to be immutable too, you need Object.freeze() on top of const. Get this distinction straight now, and you won't be surprised later when you see someone push a new element into a const array.

Global Object Attachment

In a browser's top-level scope, a var-declared variable becomes a property on the window object. let and const don't do this.

js
var globalVar = 'foo'
let globalLet = 'bar'
 
console.log(window.globalVar) // 'foo'
console.log(window.globalLet) // undefined

That means declaring var variables carelessly at the global level is effectively stuffing properties onto window — one careless name collision and you're clobbering some other library's code, or having yours clobbered. It's yet another way var gets you into trouble.

So Which One Should You Actually Reach For?

Pulling all of this together boils down to a pretty simple decision order:

  1. Default to const. If a variable doesn't need reassigning after it's declared, use const. It lets anyone reading the code see at a glance that this value never gets swapped out later, which cuts down the mental overhead of tracing the logic.
  2. Reach for let only when you need reassignment — loop counters, accumulators that get overwritten, cases where you know upfront the value will change.
  3. var basically has no place in new code. Its function-scoping, its tolerance for redeclaration, and the way it leaks onto window all carry far more risk than benefit in a modern JavaScript codebase. Unless you're maintaining legacy code or need to support truly ancient runtimes, there's no reason to reach for it.

Wrapping Up

On the surface, var, let, and const look like three ways to write the same thing: declaring a variable. Underneath, they trace an evolution in how JavaScript thinks about scope — from var's function-boundary, redeclaration-friendly, silently-global-leaking behavior, to let and const's block-boundary, redeclaration-blocking, TDZ-enforced approach. One rule is enough to remember: reach for const before let, and let before var.