Arunkumar

JavaScript Variables: var, const, let, and Hoisting Explained

17 min read3,239 wordsJavaScript

Why Understanding Variable Declarations Matters

JavaScript provides three ways to declare variables: var, const, and let. Each has distinct behaviors that affect:

  • Scope - Where variables are accessible
  • Hoisting - How variables are processed before execution
  • Reassignment - Whether values can change
  • Initialization - When variables can be used

Understanding these differences helps you:

  • Write more predictable code
  • Avoid common bugs
  • Follow modern JavaScript best practices
  • Debug scope-related issues
  • Perform better in technical interviews

This guide explains each declaration type with practical examples and real-world scenarios.


Quick Comparison Table

Feature          | var              | let              | const
-----------------|------------------|------------------|------------------
Scope            | Function/Local   | Block            | Block
Hoisting         | Yes (undefined)  | Yes (TDZ)        | Yes (TDZ)
Reassignment     | Yes              | Yes              | No
Re-declaration   | Yes              | No               | No
Initialization   | Optional         | Optional         | Required
Temporal Dead Zone| No              | Yes              | Yes

var: The Legacy Declaration

What Is var?

var is the original way to declare variables in JavaScript. It was introduced in the first version of JavaScript and has function-scoped behavior.

Key Characteristics

  • Function-scoped (or globally-scoped if declared outside a function)
  • Hoisted to the top of its scope
  • Can be re-declared in the same scope
  • Can be reassigned
  • No Temporal Dead Zone (can be accessed before declaration)

Scope Behavior

function example() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 - accessible outside the block
}

example();
console.log(x); // ReferenceError: x is not defined

Important: var is function-scoped, not block-scoped. Variables declared with var inside blocks (if, for, while) are accessible throughout the entire function.

Hoisting with var

console.log(x); // undefined (not ReferenceError!)
var x = 5;
console.log(x); // 5

What happens:

  1. JavaScript hoists the declaration (var x;) to the top
  2. Variable is initialized with undefined
  3. Assignment happens at the original line
  4. This is equivalent to:
var x; // hoisted declaration
console.log(x); // undefined
x = 5; // assignment
console.log(x); // 5

Re-declaration

var x = 10;
var x = 20; // No error - re-declaration allowed
console.log(x); // 20

Common Pitfalls with var

1. Loop Variable Leakage

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // Prints 3, 3, 3 (not 0, 1, 2!)
  }, 100);
}

Why? The var i is function-scoped, so all callbacks reference the same variable after the loop completes.

Solution: Use let (see below) or create a closure:

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => {
      console.log(j); // Prints 0, 1, 2
    }, 100);
  })(i);
}

2. Accidental Global Variables

function example() {
  x = 10; // Missing var/let/const - creates global variable!
}
example();
console.log(x); // 10 - accessible globally (bad!)

Always declare variables explicitly.

3. Variable Shadowing Issues

var x = 10;

function test() {
  var x = 20; // Shadows outer x
  if (true) {
    var x = 30; // Re-declares same variable
    console.log(x); // 30
  }
  console.log(x); // 30 (not 20!)
}

test();
console.log(x); // 10

let: Block-Scoped Variables

What Is let?

let was introduced in ES6 (ES2015) to address var's limitations. It provides block-scoped variables with better predictability.

Key Characteristics

  • Block-scoped - Limited to the nearest enclosing block
  • Hoisted but in Temporal Dead Zone (TDZ)
  • Cannot be re-declared in the same scope
  • Can be reassigned
  • Temporal Dead Zone - Cannot be accessed before declaration

Scope Behavior

function example() {
  if (true) {
    let x = 10;
    console.log(x); // 10
  }
  console.log(x); // ReferenceError: x is not defined
}

example();

Important: let is block-scoped. Variables are only accessible within the block where they're declared.

Hoisting and Temporal Dead Zone

console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;

What happens:

  1. JavaScript hoists the declaration (let x;) to the top
  2. Variable enters Temporal Dead Zone (TDZ)
  3. Accessing variable in TDZ throws ReferenceError
  4. Variable is initialized when execution reaches the declaration line

TDZ exists from the start of the scope until the declaration line:

{
  // TDZ starts here
  console.log(x); // ReferenceError - still in TDZ
  let x = 5; // TDZ ends here
  console.log(x); // 5
}

No Re-declaration

let x = 10;
let x = 20; // SyntaxError: Identifier 'x' has already been declared

Exception: Different scopes can have variables with the same name:

let x = 10;
if (true) {
  let x = 20; // Different scope - OK
  console.log(x); // 20
}
console.log(x); // 10

Loop Variable Behavior

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // Prints 0, 1, 2 (correct!)
  }, 100);
}

Why? Each iteration creates a new binding for i, so each callback captures its own copy.

When to Use let

  • When you need to reassign the variable
  • In loops (for, while, for...of, for...in)
  • When you need block scope
  • For variables that change over time
let count = 0;
for (let i = 0; i < 10; i++) {
  count += i;
}
console.log(count); // 45

const: Immutable Bindings

What Is const?

const was introduced in ES6 alongside let. It creates block-scoped variables that cannot be reassigned.

Key Characteristics

  • Block-scoped - Same as let
  • Hoisted but in Temporal Dead Zone (TDZ)
  • Cannot be re-declared in the same scope
  • Cannot be reassigned - Binding is immutable
  • Must be initialized when declared
  • Temporal Dead Zone - Cannot be accessed before declaration

Scope Behavior

function example() {
  if (true) {
    const x = 10;
    console.log(x); // 10
  }
  console.log(x); // ReferenceError: x is not defined
}

example();

Must Be Initialized

const x; // SyntaxError: Missing initializer in const declaration
const y = 10; // OK

Immutability: Binding vs Value

Important distinction: const prevents reassignment of the binding, not mutation of the value.

Primitive Values (Truly Immutable)

const x = 10;
x = 20; // TypeError: Assignment to constant variable

Objects and Arrays (Binding Immutable, Value Mutable)

const obj = { name: "John" };
obj.name = "Jane"; // OK - mutating property
obj.age = 30; // OK - adding property
obj = {}; // TypeError: Assignment to constant variable

const arr = [1, 2, 3];
arr.push(4); // OK - mutating array
arr[0] = 10; // OK - mutating element
arr = []; // TypeError: Assignment to constant variable

To make objects/arrays immutable, use:

  • Object.freeze() - Shallow freeze
  • Object.seal() - Prevents adding/removing properties
  • Libraries like Immutable.js
  • Deep cloning before modification
const obj = Object.freeze({ name: "John" });
obj.name = "Jane"; // Silent failure in strict mode, TypeError otherwise

When to Use const

  • For values that shouldn't be reassigned
  • For function declarations (use const with arrow functions)
  • For imported modules
  • For configuration values
  • Default choice - Use const unless you need reassignment
const PI = 3.14159;
const API_URL = "https://api.example.com";
const config = { timeout: 5000 };

// Function expressions
const greet = (name) => `Hello, ${name}!`;

// Array/object references
const users = [];
const settings = {};

Hoisting: How JavaScript Processes Variables

What Is Hoisting?

Hoisting is JavaScript's behavior of moving variable and function declarations to the top of their containing scope during compilation before code execution.

Important: Only declarations are hoisted, not initializations.

How Hoisting Works

console.log(x); // undefined
var x = 5;

JavaScript processes this as:

var x; // Declaration hoisted
console.log(x); // undefined
x = 5; // Assignment stays in place

Hoisting with var

function example() {
  console.log(x); // undefined
  var x = 10;
  console.log(x); // 10
}

Execution order:

  1. Declaration (var x;) hoisted to top
  2. Variable initialized with undefined
  3. Code executes line by line
  4. Assignment happens at original location

Hoisting with let and const

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

What happens:

  1. Declaration (let x;) hoisted to top
  2. Variable enters Temporal Dead Zone
  3. Accessing variable throws ReferenceError
  4. Variable initialized when execution reaches declaration

Function Hoisting

Functions are also hoisted, but behavior differs by declaration type:

Function Declarations (Fully Hoisted)

sayHello(); // "Hello!" - Works!

function sayHello() {
  console.log("Hello!");
}

Function declarations are hoisted entirely - both declaration and definition.

Function Expressions (Not Hoisted)

sayHello(); // TypeError: sayHello is not a function

var sayHello = function () {
  console.log("Hello!");
};

What happens:

  1. var sayHello hoisted (undefined)
  2. Calling sayHello() throws TypeError
  3. Function assigned later

Arrow Functions (Not Hoisted)

sayHello(); // ReferenceError: Cannot access 'sayHello' before initialization

const sayHello = () => {
  console.log("Hello!");
};

Arrow functions follow the hoisting rules of their declaration type (const, let, or var).

Hoisting Order

JavaScript hoists in this order:

  1. Function declarations
  2. Variable declarations (var)
  3. let and const declarations (but remain in TDZ)
console.log(typeof x); // "function" (function declaration wins)
var x = 10;
function x() {
  return "function";
}
console.log(typeof x); // "number"

Practical Hoisting Examples

Example 1: Variable Shadowing

var x = 10;

function test() {
  console.log(x); // undefined (not 10!)
  var x = 20;
  console.log(x); // 20
}

test();

Why undefined? The local var x is hoisted, shadowing the outer x.

Example 2: Loop with var

var funcs = [];
for (var i = 0; i < 3; i++) {
  funcs.push(function () {
    return i;
  });
}

console.log(funcs[0]()); // 3 (all functions return 3)
console.log(funcs[1]()); // 3
console.log(funcs[2]()); // 3

Why? All functions reference the same hoisted var i after the loop completes.

Solution with let:

var funcs = [];
for (let i = 0; i < 3; i++) {
  funcs.push(function () {
    return i;
  });
}

console.log(funcs[0]()); // 0
console.log(funcs[1]()); // 1
console.log(funcs[2]()); // 2

Scope: Where Variables Live

Scope Types

1. Global Scope

Variables declared outside any function or block:

var globalVar = "I'm global";
let globalLet = "I'm also global";
const globalConst = "Me too";

function test() {
  console.log(globalVar); // Accessible
  console.log(globalLet); // Accessible
  console.log(globalConst); // Accessible
}

2. Function Scope

Variables accessible within the entire function (var):

function example() {
  if (true) {
    var x = 10;
  }
  console.log(x); // 10 - accessible throughout function
}

3. Block Scope

Variables accessible only within the block (let, const):

function example() {
  if (true) {
    let x = 10;
    const y = 20;
  }
  console.log(x); // ReferenceError
  console.log(y); // ReferenceError
}

Scope Chain

JavaScript uses lexical scoping - inner scopes can access outer scopes:

const global = "global";

function outer() {
  const outerVar = "outer";

  function inner() {
    const innerVar = "inner";
    console.log(global); // "global" - from global scope
    console.log(outerVar); // "outer" - from outer scope
    console.log(innerVar); // "inner" - from inner scope
  }

  inner();
}

outer();

Shadowing

Inner scopes can declare variables with the same name as outer scopes:

let x = "outer";

function test() {
  let x = "inner";
  console.log(x); // "inner"
}

test();
console.log(x); // "outer"

Best practice: Avoid shadowing to prevent confusion.


Temporal Dead Zone (TDZ)

What Is TDZ?

The Temporal Dead Zone is the period between entering a scope and the variable declaration where accessing the variable throws a ReferenceError.

TDZ with let

{
  // TDZ starts
  console.log(x); // ReferenceError
  let x = 10; // TDZ ends
  console.log(x); // 10
}

TDZ with const

{
  // TDZ starts
  console.log(x); // ReferenceError
  const x = 10; // TDZ ends
  console.log(x); // 10
}

Why TDZ Exists

TDZ prevents accessing variables before initialization, catching bugs early:

// Without TDZ (var behavior - buggy)
console.log(x); // undefined (silent bug)
var x = 10;

// With TDZ (let/const - catches error)
console.log(x); // ReferenceError (catches bug immediately)
let x = 10;

TDZ Examples

Example 1: TDZ in Same Scope

function test() {
  console.log(x); // ReferenceError
  let x = 10;
}

Example 2: TDZ Across Blocks

{
  console.log(x); // ReferenceError - x is in TDZ
  let x = 10;
}

Example 3: TDZ with typeof

console.log(typeof x); // "undefined" (safe for var)
console.log(typeof y); // ReferenceError (TDZ for let)
let y = 10;

Note: typeof is safe with var but throws in TDZ for let/const.


Best Practices

1. Prefer const by Default

// ✅ Good: Use const unless reassignment needed
const users = [];
const config = { apiUrl: "https://api.example.com" };

// Only use let when you need reassignment
let count = 0;
count++; // Reassignment needed

2. Use let for Loop Variables

// ✅ Good
for (let i = 0; i < items.length; i++) {
  // ...
}

// ❌ Avoid
for (var i = 0; i < items.length; i++) {
  // ...
}

3. Avoid var in Modern JavaScript

// ❌ Avoid var
var x = 10;

// ✅ Use const or let
const x = 10; // If no reassignment
let y = 10; // If reassignment needed

4. Declare Variables at the Top of Scope

// ✅ Good: Clear and predictable
function example() {
  const x = 10;
  const y = 20;

  if (condition) {
    // ...
  }
}

// ❌ Avoid: Scattered declarations
function example() {
  if (condition) {
    const x = 10;
  }
  const y = 20;
}

5. Initialize Variables When Declaring

// ✅ Good
const name = "John";
let count = 0;

// ❌ Avoid (when possible)
let name;
// ... later
name = "John";

6. Use Descriptive Names

// ✅ Good
const userCount = 10;
const apiBaseUrl = "https://api.example.com";

// ❌ Avoid
const uc = 10;
const url = "https://api.example.com";

7. Group Related Declarations

// ✅ Good: Grouped by purpose
const API_BASE_URL = "https://api.example.com";
const API_TIMEOUT = 5000;
const API_RETRIES = 3;

let userCount = 0;
let activeUsers = [];

Common Mistakes and How to Avoid Them

Mistake 1: Using var in Loops

// ❌ Problem
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // Prints 3, 3, 3
}

// ✅ Solution
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // Prints 0, 1, 2
}

Mistake 2: Reassigning const

// ❌ Problem
const arr = [1, 2, 3];
arr = [4, 5, 6]; // TypeError

// ✅ Solution
let arr = [1, 2, 3];
arr = [4, 5, 6]; // OK

Mistake 3: Accessing Before Declaration

// ❌ Problem
console.log(x); // ReferenceError
let x = 10;

// ✅ Solution
let x = 10;
console.log(x); // 10

Mistake 4: Assuming const Makes Objects Immutable

// ❌ Problem: Thinking const prevents mutation
const obj = { name: "John" };
obj.name = "Jane"; // This works! const only prevents reassignment

// ✅ Solution: Use Object.freeze() if needed
const obj = Object.freeze({ name: "John" });
obj.name = "Jane"; // Fails in strict mode

Mistake 5: Re-declaring in Same Scope

// ❌ Problem
let x = 10;
let x = 20; // SyntaxError

// ✅ Solution
let x = 10;
x = 20; // Reassignment, not re-declaration

Real-World Examples

Example 1: Configuration Object

// ✅ Good: Use const for configuration
const config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  retries: 3,
};

// Mutating properties is OK
config.timeout = 10000;

// Reassignment is not
// config = {}; // TypeError

Example 2: Event Handlers

// ✅ Good: Use const for function references
const handleClick = (event) => {
  console.log("Clicked:", event.target);
};

button.addEventListener("click", handleClick);

Example 3: Loop with Closures

// ✅ Good: let creates new binding each iteration
const buttons = document.querySelectorAll("button");

for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener("click", () => {
    console.log(`Button ${i} clicked`); // Correct index
  });
}

Example 4: Module Exports

// ✅ Good: Use const for exports
const API_BASE_URL = "https://api.example.com";
const MAX_RETRIES = 3;

export { API_BASE_URL, MAX_RETRIES };

Example 5: State Management

// ✅ Good: Use let for changing state
let currentUser = null;

function login(user) {
  currentUser = user; // Reassignment needed
}

function logout() {
  currentUser = null; // Reassignment needed
}

Interview Questions and Answers

Q1: What's the difference between var, let, and const?

Answer:

  • var: Function-scoped, hoisted with undefined, can be re-declared
  • let: Block-scoped, hoisted but in TDZ, cannot be re-declared, can be reassigned
  • const: Block-scoped, hoisted but in TDZ, cannot be re-declared, cannot be reassigned, must be initialized

Q2: What is hoisting?

Answer: Hoisting is JavaScript's behavior of moving declarations to the top of their scope. Only declarations are hoisted, not initializations. var is hoisted with undefined, while let and const are hoisted but remain in Temporal Dead Zone.

Q3: What is the Temporal Dead Zone?

Answer: TDZ is the period between entering a scope and the variable declaration where accessing the variable throws a ReferenceError. It exists for let and const but not for var.

Q4: Can you reassign a const variable?

Answer: No, you cannot reassign the binding. However, if const holds an object or array, you can mutate its properties/elements. The binding is immutable, not the value.

Q5: Why does this code print 3, 3, 3?

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}

Answer: Because var is function-scoped, all callbacks reference the same i variable after the loop completes (value 3). Use let to create a new binding each iteration.


Key Takeaways

  1. Use const by default - Only use let when you need reassignment
  2. Avoid var - Use let or const in modern JavaScript
  3. Understand scope - var is function-scoped, let/const are block-scoped
  4. Know hoisting - All declarations are hoisted, but behavior differs
  5. Respect TDZ - let/const cannot be accessed before declaration
  6. const prevents reassignment - Not mutation of objects/arrays
  7. Block scope prevents leaks - Use let/const in loops and blocks
  8. Declare at top - Makes code more predictable and easier to read

Summary

Understanding var, let, const, and hoisting is fundamental to writing effective JavaScript:

  • var: Legacy, function-scoped, avoid in modern code
  • let: Block-scoped, reassignable, use when values change
  • const: Block-scoped, immutable binding, use by default
  • Hoisting: All declarations move to top, but initialization differs
  • TDZ: Prevents accessing let/const before declaration

Modern JavaScript best practice: Use const by default, let when reassignment is needed, and avoid var entirely.

Master these concepts, and you'll write more predictable, maintainable JavaScript code.

Related Articles