The Ultimate Deep Dive into JavaScript's var, let, and const

Understanding Variable Declarations in Modern JavaScript
JavaScript has evolved significantly over the years, and one of the most impactful changes came with ES6 (ECMAScript 2015), which introduced let and const as alternatives to the traditional var. While many developers know the basic differences, understanding the nuances can save you from subtle bugs and help you write more maintainable code.
Introduction
Variable declaration is fundamental to any programming language. In JavaScript, the way you declare variables affects their scope, hoisting behavior, and mutability. Understanding these differences isn't just academic—it directly impacts the reliability and maintainability of your code.
// Three ways to declare variables in JavaScript
var oldSchool = "I'm from the past";
let modern = "I'm block-scoped";
const immutable = "You can't reassign me";The var Keyword - The Old Guard
Function Scope vs Block Scope
The var keyword creates function-scoped or globally-scoped variables, but NOT block-scoped. This is the root of many confusing behaviors.
function varExample() {
if (true) {
var x = 10;
}
console.log(x); // Output: 10 (accessible outside the if block!)
}
varExample();In the example above, even though x was declared inside the if block, it's accessible throughout the entire function. This is because var ignores block boundaries.
Hoisting with var
Variables declared with var are hoisted to the top of their scope and initialized with undefined.
function hoistingExample() {
console.log(message); // Output: undefined (not ReferenceError!)
var message = "Hello, World!";
console.log(message); // Output: Hello, World!
}
hoistingExample();What JavaScript actually executes:
function hoistingExample() {
var message; // Declaration hoisted
console.log(message); // undefined
message = "Hello, World!"; // Assignment stays in place
console.log(message);
}Re-declaration Allowed
With var, you can declare the same variable multiple times in the same scope without errors.
var name = "John";
var name = "Jane"; // No error - second declaration overwrites
console.log(name); // Output: JaneThis can lead to accidental overwrites in large codebases.
Global Object Property
When declared globally, var creates a property on the global object (window in browsers).
var globalVar = "I'm on the window object";
console.log(window.globalVar); // Output: I'm on the window objectThe let Keyword - Block-Scoped Revolution
True Block Scope
let respects block boundaries, making code more predictable and preventing accidental variable leakage.
function letExample() {
if (true) {
let x = 10;
console.log(x); // Output: 10
}
console.log(x); // ReferenceError: x is not defined
}
letExample();Block Scope in Loops
One of the most significant improvements with let is in loop constructs:
// Problem with var
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3 (all callbacks see the final value of i)
// Solution with let
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100);
}
// Output: 0, 1, 2 (each iteration has its own j)The let keyword creates a new binding for each iteration, solving the classic closure problem.
No Re-declaration
Unlike var, you cannot re-declare a variable with let in the same scope:
let name = "John";
let name = "Jane"; // SyntaxError: Identifier 'name' has already been declaredHoisting Behavior
let is hoisted but not initialized, creating what's called the Temporal Dead Zone (TDZ).
function tdzExample() {
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
}Not Added to Global Object
Even when declared globally, let doesn't create properties on the global object:
let globalLet = "I'm not on window";
console.log(window.globalLet); // Output: undefinedThe const Keyword - Immutable Bindings
Constant Reference, Not Constant Value
const creates a constant reference to a value, but if that value is an object or array, the contents can still be modified.
const PI = 3.14159;
PI = 3.14; // TypeError: Assignment to constant variable
const person = { name: "John", age: 30 };
person.age = 31; // This works! The object is mutable
person.city = "New York"; // This also works!
console.log(person); // { name: 'John', age: 31, city: 'New York' }
person = {}; // TypeError: Assignment to constant variableMust Be Initialized
Unlike var and let, const must be initialized at declaration:
let x; // OK
const y; // SyntaxError: Missing initializer in const declarationBlock Scoped Like let
const follows the same scoping rules as let:
if (true) {
const blockScoped = "I'm trapped here";
}
console.log(blockScoped); // ReferenceErrorArrays and Objects with const
This is a common source of confusion for beginners:
const numbers = [1, 2, 3];
numbers.push(4); // Works fine
numbers[0] = 99; // Works fine
console.log(numbers); // [99, 2, 3, 4]
numbers = []; // TypeError: Assignment to constant variableTo make an object truly immutable, you need Object.freeze():
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000
});
config.timeout = 10000; // Silently fails in non-strict mode
console.log(config.timeout); // Still 5000
// For nested objects, you need deep freezing
const deepConfig = Object.freeze({
server: Object.freeze({
host: "localhost",
port: 3000
})
});Hoisting Behavior Deep Dive
Hoisting is JavaScript's behavior of moving declarations to the top of their scope during compilation.
Visual Representation
// What you write
console.log(a); // undefined
console.log(b); // ReferenceError
console.log(c); // ReferenceError
var a = 1;
let b = 2;
const c = 3;
// How JavaScript interprets it
var a; // Hoisted and initialized to undefined
// let b; // Hoisted but NOT initialized (TDZ)
// const c; // Hoisted but NOT initialized (TDZ)
console.log(a); // undefined
console.log(b); // ReferenceError
console.log(c); // ReferenceError
a = 1;
b = 2;
c = 3;Function Declarations vs Expressions
// Function declarations are fully hoisted
hoistedFunction(); // Works!
function hoistedFunction() {
console.log("I'm hoisted!");
}
// Function expressions with var
notHoisted(); // TypeError: notHoisted is not a function
var notHoisted = function() {
console.log("I'm not hoisted!");
};
// Function expressions with let/const
alsoNotHoisted(); // ReferenceError
const alsoNotHoisted = function() {
console.log("I'm also not hoisted!");
};The Temporal Dead Zone (TDZ)
The TDZ is the period between entering a scope and the actual declaration of a let or const variable.
function demonstrateTDZ() {
// TDZ starts here for myVar
console.log(myVar); // ReferenceError: Cannot access 'myVar' before initialization
let myVar = 10; // TDZ ends here
console.log(myVar); // 10
}TDZ with Function Parameters
function example(a = b, b) {
return a + b;
}
example(undefined, 2); // ReferenceError: Cannot access 'b' before initialization
// The default parameter a = b tries to access b before it's initializedTDZ in Different Scopes
let x = 1;
{
// TDZ for x starts here
console.log(x); // ReferenceError (not the outer x!)
let x = 2; // TDZ ends here
}Real-World Problems and Solutions
Problem 1: The Classic Loop Closure Bug
Problem:
// Creating buttons in a loop
const buttons = document.querySelectorAll('.button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
alert('Button ' + i + ' clicked');
});
}
// All buttons alert the same number (buttons.length)Solution with let:
for (let i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
alert('Button ' + i + ' clicked');
});
}
// Each button alerts the correct numberWhy it works: let creates a new binding for each iteration, so each closure captures its own i.
Problem 2: Accidental Global Variables
Problem:
function processData() {
// Forgot to declare with var/let/const
result = calculateSomething();
return result;
}
processData();
console.log(window.result); // Oops! Global variable createdSolution:
function processData() {
const result = calculateSomething();
return result;
}
// Use strict mode to catch these errors
"use strict";
function safeProcess() {
result = calculateSomething(); // ReferenceError in strict mode
return result;
}Problem 3: Configuration Objects
Problem:
// Using let for configuration that shouldn't change
let API_CONFIG = {
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123'
};
// Somewhere else in the code
API_CONFIG = { baseUrl: 'https://malicious-site.com' }; // Oops!Solution:
const API_CONFIG = {
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123'
};
// This will throw an error
API_CONFIG = { baseUrl: 'https://malicious-site.com' }; // TypeError
// If you need true immutability
const FROZEN_CONFIG = Object.freeze({
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123'
});
FROZEN_CONFIG.baseUrl = 'https://malicious-site.com'; // Silently failsProblem 4: React State Management Confusion
Problem:
// Anti-pattern: using let with React state
function Counter() {
let count = 0; // This won't cause re-renders!
const increment = () => {
count++; // Updates the variable but doesn't trigger re-render
console.log(count); // Logs correct value
};
return (
<div>
<p>Count: {count}</p> {/* Always shows 0 */}
<button onClick={increment}>Increment</button>
</div>
);
}Solution:
// Proper React state management
function Counter() {
const [count, setCount] = useState(0); // Use const with useState
const increment = () => {
setCount(count + 1); // Triggers re-render
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}Problem 5: Module Pattern Leakage
Problem:
// moduleA.js
var sharedData = "I shouldn't be shared";
function moduleA() {
console.log(sharedData);
}
// moduleB.js
var sharedData = "Different data"; // Overwrites moduleA's variable!
function moduleB() {
console.log(sharedData);
}Solution:
// moduleA.js
const sharedData = "Module A data"; // Block scoped, won't leak
export function moduleA() {
console.log(sharedData);
}
// moduleB.js
const sharedData = "Module B data"; // Independent scope
export function moduleB() {
console.log(sharedData);
}Problem 6: Conditional Variable Declaration
Problem:
function getUserStatus(isLoggedIn) {
if (isLoggedIn) {
var status = "online";
} else {
var status = "offline";
}
return status; // Works, but confusing
}Better approach:
function getUserStatus(isLoggedIn) {
const status = isLoggedIn ? "online" : "offline";
return status;
}
// Or even better
function getUserStatus(isLoggedIn) {
if (isLoggedIn) {
return "online";
}
return "offline";
}Performance Considerations
Memory Management
// var can lead to memory leaks in long-running applications
function createClosure() {
var largeArray = new Array(1000000).fill('data');
return function() {
console.log(largeArray.length);
};
}
// Better with let/const - more predictable cleanup
function createClosure() {
const largeArray = new Array(1000000).fill('data');
return function() {
console.log(largeArray.length);
};
}Engine Optimizations
Modern JavaScript engines can optimize const better than let or var because they know the binding won't change:
// Engine can optimize this better
const MAX_USERS = 1000;
for (let i = 0; i < MAX_USERS; i++) {
// Process users
}
// vs
let maxUsers = 1000; // Engine must watch for reassignment
for (let i = 0; i < maxUsers; i++) {
// Process users
}Best Practices
1. Default to const
Start with const by default. Only use let when you know you'll need to reassign.
// Good
const userName = "John Doe";
const userAge = 30;
let userScore = 0; // Will be updated
function updateScore() {
userScore += 10;
}2. Never Use var in New Code
Unless you're maintaining legacy code or need specific var behavior (extremely rare), stick with let and const.
// Avoid
var oldStyle = "outdated";
// Prefer
const modernStyle = "current";3. Declare Variables at the Top of Their Scope
Make your code more readable by declaring variables where they'll be used:
// Less ideal
function processUser() {
// ... lots of code ...
let userId = getUserId();
// ... more code using userId ...
}
// Better
function processUser() {
const userId = getUserId();
// ... code using userId ...
}4. Use Block Scope to Your Advantage
Limit variable lifetime to reduce bugs:
function calculateTotal(items) {
let total = 0;
{
// Temporary calculation scope
const taxRate = 0.08;
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
total = subtotal * (1 + taxRate);
}
// taxRate and subtotal no longer accessible
return total;
}5. Freeze Configuration Objects
Protect important configuration:
const CONFIG = Object.freeze({
API_URL: 'https://api.example.com',
TIMEOUT: 5000,
MAX_RETRIES: 3
});
// For nested objects
const DEEP_CONFIG = Object.freeze({
server: Object.freeze({
host: 'localhost',
port: 3000
}),
database: Object.freeze({
host: 'db.example.com',
port: 5432
})
});6. Use Linting Rules
Configure ESLint to enforce best practices:
{
"rules": {
"no-var": "error",
"prefer-const": "error",
"no-const-assign": "error"
}
}Comparison Table
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function/Global | Block | Block |
| Hoisting | Yes (initialized as undefined) | Yes (TDZ applies) | Yes (TDZ applies) |
| Re-declaration | Allowed | Not allowed | Not allowed |
| Re-assignment | Allowed | Allowed | Not allowed |
| Global object property | Yes | No | No |
| Temporal Dead Zone | No | Yes | Yes |
| Must be initialized | No | No | Yes |
| Best for | Legacy code | Variables that change | Constants and objects |
Conclusion
Understanding the differences between var, let, and const is crucial for writing robust JavaScript code. Here are the key takeaways:
-
Use
constby default - It prevents accidental reassignment and signals intent clearly. -
Use
letwhen you need reassignment - Perfect for loop counters, toggles, and accumulator variables. -
Avoid
varin modern code - Its function-scoping and hoisting behavior lead to confusing bugs. -
Remember
constprotects the binding, not the value - Objects and arrays declared withconstcan still be modified. -
Be aware of the Temporal Dead Zone - It helps catch errors but can be surprising if you're not expecting it.
-
Block scope is your friend - It makes code more predictable and prevents variable leakage.
As JavaScript continues to evolve, these fundamentals remain critical. Master them, and you'll write cleaner, more maintainable code that's easier for others (and future you) to understand and debug.