Differences between var, let and const
var, let, and const - the three keywords for declaring variables in JavaScript, each with different scoping rules, hoisting behavior, and constraints on reassignment.
Theory
TL;DR
varis function-scoped;letandconstare block-scoped- All three hoist, but
varinitializes toundefinedwhilelet/conststay in the temporal dead zone (TDZ) until the declaration line constblocks reassignment;varandletallow it- Default choice:
const. Useletonly when reassignment is needed. Avoidvarin new code - Analogy:
varis a whiteboard in the hallway (visible from anywhere in the function);let/constare notes pinned inside a specific room (visible only in that block)
Quick example
console.log(varX); // undefined (hoisted, initialized to undefined)
var varX = 1;
console.log(letX); // ReferenceError: Cannot access 'letX' before initialization
let letX = 2;
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // logs 3, 3, 3 (shared var)
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 0); // logs 0, 1, 2 (fresh binding per iteration)
}var in a loop shares one i across all iterations. let creates a fresh binding per iteration, so each closure captures its own value.
Key difference
var ignores if, for, and while blocks completely. It belongs to the nearest function or to global scope. let and const respect curly braces, so a variable declared inside a block stays there. That one rule explains most bugs that come from var leaking into outer scopes.
When to use
- Config values, API keys, imported modules →
const - Loop counters that need incrementing, mutable locals →
let - Any value that gets reassigned →
let - Any value that never changes →
const(including objects and arrays) - Maintaining existing legacy codebases only →
var
Comparison table
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function / global | Block | Block |
| Hoisting | Yes, initializes to undefined | Yes, but stays in TDZ | Yes, but stays in TDZ |
| Redeclaration | Allowed | Not allowed | Not allowed |
| Reassignment | Allowed | Allowed | Not allowed |
| Initialization required | No | No | Yes |
| When to use | Legacy code only | Counters, mutable vars | Default choice |
How the compiler handles this
In V8, the engine creates a LexicalEnvironment per scope during compilation. var bindings go into the function's VariableEnvironment and are immediately set to undefined, which is why reading a var before its declaration line gives undefined instead of an error. let and const also enter the LexicalEnvironment at compile time, but their binding sits in an uninitialized state. Reading them before the declaration line triggers a ReferenceError. That window between entering scope and the declaration line is the TDZ.
Common mistakes
Mistake 1: var in loops with async callbacks
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // logs 3, 3, 3
}All callbacks share one i because var is function-scoped. By the time they fire, the loop is done and i is 3. This is a classic interview trap. Fix: use let.
Mistake 2: Thinking const means immutable
const arr = [1, 2];
arr.push(3); // works fine, arr is now [1, 2, 3]
arr = [4, 5]; // TypeError: Assignment to constant variableconst prevents reassignment of the variable binding itself, not mutation of the value it holds. For a truly immutable array or object, use Object.freeze().
Mistake 3: Reading let or const before the declaration
console.log(x); // ReferenceError, not undefined
let x = 5;This does not silently return undefined like var would. The variable is in TDZ. The engine knows it exists but refuses access until the declaration line runs.
Real-world usage
- React:
constfor hooks (const [count, setCount] = useState(0)), component definitions, all imports - Node.js / Express:
const app = express(),const router = express.Router() - Redux:
const FETCH_USER = 'FETCH_USER'for action type constants - Loops with mutable counters:
for (let i = 0; i < arr.length; i++)
Follow-up questions
Q: What is the temporal dead zone?
A: The TDZ is the period between a let or const variable entering scope (at compile time) and reaching its declaration line. Any access in that window throws a ReferenceError. var has no TDZ because it initializes to undefined right away.
Q: Can you mutate an object declared with const?
A: Yes. const obj = {}; obj.name = 'Alice' works fine. const blocks reassignment only: obj = {} throws a TypeError. Use Object.freeze(obj) if you need the object itself to be immutable.
Q: Why does for (var i = 0; ...) with setTimeout always log the final value of i?
A: All iterations share one i because var is function-scoped. Closures capture a reference to that shared variable, not a snapshot. When the callbacks fire, the loop is already done and i is at its final value. Switching to let gives each iteration its own binding.
Q: Does top-level var attach to window in Node.js?
A: No. Browsers attach top-level var to window. In Node.js every file runs inside a module wrapper function, so top-level var stays local to that module.
Q: Walk through what V8 does when let is shadowed in a nested block.
A: V8 creates a new LexicalEnvironment for each block during compilation. The outer let binding goes into the outer env. When execution enters the inner block, V8 creates a second LexicalEnvironment chained to the outer one. The inner let goes there and shadows the outer binding. Variable lookup walks up the chain, so inner code finds its own binding first. Both bindings start in TDZ until their declaration lines execute.
Examples
Scope leak: var vs let
function checkScope() {
if (true) {
var x = 10; // leaks out to function scope
let y = 20; // stays inside this block
}
console.log(x); // 10, var leaked out
console.log(y); // ReferenceError, let did not leak
}
checkScope();var does not respect the if block boundary. let does. One variable escapes the block, the other stays put. That difference is the entire reason let exists.
React component with proper declarations
function UserList({ users }) {
const [filteredUsers, setFilteredUsers] = useState(users);
return (
<ul>
{filteredUsers.map(user => {
const userId = user.id; // fresh const per iteration block, never reassigned
return <li key={userId}>{user.name}</li>;
})}
</ul>
);
}Everything here is const because nothing gets reassigned. setFilteredUsers is the function you call to schedule a state update, not a value you overwrite directly. userId is a new block-scoped constant on every iteration.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.