Master var, let, const and Understand Scope
Variables are containers for storing data values. In JavaScript, you can declare variables using three keywords: var, let, and const. Each has different characteristics and use cases.
var was the original way to declare variables in JavaScript. It has some quirks that can lead to bugs:
var name = 'Alice';
var age = 30;
// var can be redeclared
var name = 'Bob'; // No error!
// var is function-scoped, not block-scoped
if (true) {
var message = 'Hello';
}
console.log(message); // 'Hello' - accessible outside the block!
let was introduced in ES6 (2015) and provides block-level scoping:
let name = 'Alice';
let age = 30;
// let cannot be redeclared in the same scope
// let name = 'Bob'; // SyntaxError!
// let is block-scoped
if (true) {
let message = 'Hello';
console.log(message); // 'Hello'
}
// console.log(message); // ReferenceError: message is not defined
// But let can be reassigned
age = 31; // This works
const declares constants that cannot be reassigned:
const PI = 3.14159;
const MAX_SIZE = 100;
// const cannot be reassigned
// PI = 3.14; // TypeError: Assignment to constant variable
// const must be initialized
// const VALUE; // SyntaxError: Missing initializer
// But const objects can be mutated
const person = { name: 'Alice' };
person.name = 'Bob'; // This works!
person.age = 30; // This works!
// You just can't reassign the variable itself
// person = {}; // TypeError!
var is function-scoped, meaning it's accessible anywhere within the function where it's declared:
function example() {
var x = 1;
if (true) {
var x = 2; // Same variable!
console.log(x); // 2
}
console.log(x); // 2 - modified by the if block
}
example();
let and const are block-scoped, meaning they're only accessible within the block where they're declared:
function example() {
let x = 1;
if (true) {
let x = 2; // Different variable!
console.log(x); // 2
}
console.log(x); // 1 - unchanged
}
example();
// Block scope works with any block
{
let blockScoped = 'I exist only in this block';
console.log(blockScoped); // Works
}
// console.log(blockScoped); // ReferenceError
// Useful in loops
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2
// Compare with var
for (var j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100);
}
// Prints: 3, 3, 3 (all reference the same variable!)
JavaScript uses lexical scoping, meaning inner functions have access to variables in their outer scopes:
const globalVar = 'global';
function outer() {
const outerVar = 'outer';
function inner() {
const innerVar = 'inner';
// Can access all three variables
console.log(globalVar); // 'global'
console.log(outerVar); // 'outer'
console.log(innerVar); // 'inner'
}
inner();
// console.log(innerVar); // ReferenceError - can't access inner scope
}
outer();
The scope chain is the hierarchy of scopes that JavaScript searches when looking for a variable:
let a = 1;
function first() {
let b = 2;
function second() {
let c = 3;
function third() {
let d = 4;
console.log(a + b + c + d); // 10
// Searches: third scope → second scope → first scope → global scope
}
third();
}
second();
}
first();
Variables declared with let and const are in a "temporal dead zone" from the start of the block until the declaration is reached:
// With var - hoisted and initialized to undefined
console.log(varVariable); // undefined
var varVariable = 'var';
// With let - hoisted but NOT initialized (TDZ)
// console.log(letVariable); // ReferenceError: Cannot access before initialization
let letVariable = 'let';
// With const - same as let
// console.log(constVariable); // ReferenceError
const constVariable = 'const';
// TDZ in action
{
// TDZ starts here for 'x'
// console.log(x); // ReferenceError
let x = 10; // TDZ ends here
console.log(x); // 10
}
Variables declared outside any function or block are in the global scope:
// Global variables
var globalVar = 'I am global';
let globalLet = 'Also global';
const globalConst = 'Global too';
// In browsers, var creates a property on window
console.log(window.globalVar); // 'I am global'
console.log(window.globalLet); // undefined (let doesn't create window property)
// Avoid polluting global scope
// Bad
var count = 0;
function increment() {
count++;
}
// Better - use modules or IIFE
(function() {
let count = 0;
function increment() {
count++;
}
})();
// Best - use ES6 modules
// export function increment() { ... }
Inner scopes can declare variables with the same name as outer scopes, "shadowing" them:
let name = 'Global';
function outer() {
let name = 'Outer';
console.log(name); // 'Outer'
function inner() {
let name = 'Inner';
console.log(name); // 'Inner'
}
inner();
console.log(name); // 'Outer'
}
outer();
console.log(name); // 'Global'
// Be careful with shadowing - it can be confusing
function confusing() {
let x = 1;
if (true) {
let x = 2; // Different x
console.log(x); // 2
}
console.log(x); // 1 - might expect 2!
}
// Bad - creates global variable
function bad() {
accidentalGlobal = 'oops'; // No var/let/const!
}
bad();
console.log(accidentalGlobal); // 'oops' - leaked to global!
// Good - always declare variables
function good() {
let intentional = 'safe';
}
// Use strict mode to catch this
'use strict';
function strict() {
// undeclared = 'error'; // ReferenceError in strict mode
}
// Bad - var leaks outside loop
for (var i = 0; i < 5; i++) {
// loop body
}
console.log(i); // 5 - still accessible!
// Good - let is block-scoped
for (let j = 0; j < 5; j++) {
// loop body
}
// console.log(j); // ReferenceError
// Bad - all callbacks reference the same i
var funcs = [];
for (var i = 0; i < 3; i++) {
funcs.push(function() {
console.log(i);
});
}
funcs[0](); // 3
funcs[1](); // 3
funcs[2](); // 3
// Good - let creates a new binding for each iteration
var funcs2 = [];
for (let i = 0; i < 3; i++) {
funcs2.push(function() {
console.log(i);
});
}
funcs2[0](); // 0
funcs2[1](); // 1
funcs2[2](); // 2
// const prevents reassignment, not mutation
const obj = { count: 0 };
obj.count = 1; // Works!
obj.newProp = 'new'; // Works!
const arr = [1, 2, 3];
arr.push(4); // Works!
arr[0] = 99; // Works!
// To make truly immutable, use Object.freeze()
const frozen = Object.freeze({ count: 0 });
frozen.count = 1; // Silently fails (throws in strict mode)
console.log(frozen.count); // 0
// For deep immutability, freeze recursively or use libraries
const deepFrozen = Object.freeze({
nested: Object.freeze({ value: 1 })
});
// Use const for values that won't be reassigned
const API_KEY = 'abc123';
const MAX_RETRIES = 3;
const user = { name: 'Alice', age: 30 };
// This makes your intent clear and prevents accidental reassignment
// Use let for values that will change
let count = 0;
count++;
let message = 'Loading...';
message = 'Complete!';
// Don't use var in modern JavaScript
// var has confusing scoping rules and no benefits over let/const
// Good - clear what variables exist in this scope
function process(data) {
const result = [];
let total = 0;
let average = 0;
// ... use variables
return { result, total, average };
}
// Bad
let x = 10;
let y = 20;
let z = x + y;
// Good
let width = 10;
let height = 20;
let area = width + height;
// Bad - wider scope than needed
function process() {
let temp = 0;
// ... lots of code
if (condition) {
temp = calculate(); // Only used here
}
}
// Good - narrower scope
function process() {
// ... lots of code
if (condition) {
const temp = calculate(); // Scoped to where it's used
}
}
// Using closures and proper scoping
function createCounter(initialValue = 0) {
let count = initialValue; // Private variable
return {
increment() {
count++;
return count;
},
decrement() {
count--;
return count;
},
getValue() {
return count;
},
reset() {
count = initialValue;
return count;
}
};
}
const counter1 = createCounter(0);
const counter2 = createCounter(100);
console.log(counter1.increment()); // 1
console.log(counter1.increment()); // 2
console.log(counter2.increment()); // 101
// Each counter has its own private count variable
console.log(counter1.getValue()); // 2
console.log(counter2.getValue()); // 101