JavaScript · Fundamentals

JavaScript Variables, Scope & Hoisting — var, let & const Explained

5 min read Updated 2026-06-17

Practice Variables, Scope & Hoisting interview questions

JavaScript variables, scope, and hoisting

var, let, and const look interchangeable until they aren't. The differences in scope, hoisting, and reassignment explain a long list of behaviors: why a variable is undefined before its line, why a let throws if accessed too early, and why the classic var loop prints the wrong numbers. This guide ties it all together.

var, let, and const

KeywordScopeHoistingReassignableRedeclarable
varfunctionyes, init undefinedyesyes
letblockyes, in TDZyesno
constblockyes, in TDZnono

The subtle one is const: it makes the binding constant, not the value. You can't reassign the variable, but you can mutate the object or array it points to.

const user = { name: 'Ada' }
user.name = 'Grace' // mutating the object
user = {}           // TypeError — reassigning the binding
const list = [1, 2]
list.push(3)        // [1, 2, 3]

Modern guidance: default to const, use let only when you must reassign, and avoid var.

Scope: function vs block

Function scope (var) makes a variable visible anywhere in the enclosing function, regardless of nested blocks. Block scope (let/const) confines it to the nearest { } — including if, for, and bare blocks.

function demo() {
  if (true) {
    var v = 'function-scoped'
    let l = 'block-scoped'
  }
  console.log(v) // 'function-scoped' — var leaks out of the if
  console.log(l) // ReferenceError — l only exists inside the if
}

This leakage is a primary reason to avoid var: block scoping keeps variables where they're relevant.

Lexical scope and the scope chain

JavaScript uses lexical (static) scope — a variable's accessibility is determined by where it's written, not where it's called. Variable lookup walks the scope chain outward (current -> enclosing -> … -> global), and the first match wins; no match is a ReferenceError.

const a = 'global'
function outer() {
  const b = 'outer'
  function inner() { return `${a} ${b}` } // reaches outward
  return inner()
}

Inner scopes see outer variables, never the reverse. (This is exactly what closures preserve.) Shadowing — declaring a same-named variable in an inner scope — is legal and creates a separate binding; redeclaring in the same scope with let/const is a SyntaxError.

Hoisting

Hoisting is the engine processing declarations before executing code, so they behave as if moved to the top of their scope. What gets hoisted differs:

  • var — hoisted and initialized to undefined (readable, just empty).
  • Function declarations — hoisted entirely (callable before their line).
  • let/const — hoisted but uninitialized (the temporal dead zone).
console.log(a) // undefined (var hoisted, value not yet assigned)
var a = 1
greet()        // 'hi' (function declaration fully hoisted)
function greet() { return 'hi' }
console.log(b) // ReferenceError (TDZ)
let b = 2

Mental model: it's two phases. A creation phase registers all declarations (var->undefined, let/const->uninitialized, functions->fully defined); an execution phase runs the code and performs assignments in place. Nothing physically moves — only assignments happen where you wrote them. Function expressions (and arrows) follow normal variable hoisting, so they're not callable before assignment.

The temporal dead zone

The TDZ is the window between entering a scope and the line where a let/const is declared. The binding exists but is uninitialized, so any access throws a ReferenceError — even typeof.

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

It exists on purpose: it turns "used before declared" from a silent undefined bug (the var behavior) into a loud, immediate error. Class declarations are also hoisted into a TDZ, so they can't be used before their line either.

The loop closure bug

The most famous scope question. With var, the loop reuses one function-scoped binding, so deferred callbacks all read its final value. let creates a fresh binding per iteration.

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)) // 0 1 2

By the time the timers fire, the var loop has finished and i is 3. let re-binds per iteration. The pre-ES6 fix was an IIFE to manufacture a new scope each pass.

Strict mode, modules, and globals

At the top level of a classic script, var creates a property on the global object (window.x); let/const do not. Assigning without declaring (x = 5) in sloppy mode creates an accidental global — strict mode throws a ReferenceError instead.

'use strict'
function f() { count = 1 } // ReferenceError (no var/let/const)

ES modules have their own top-level scope and are always strict, so even top-level var doesn't pollute the global object — a big improvement over scripts. Minimize globals: they create hidden coupling and name collisions. When you truly need one, namespace it under a single object.

Common pitfalls

  • switch and let: a switch is one block scope, so a let in one case is in the TDZ for the whole switch — wrap each case body in { }.
  • const requires initialization (const x; is a SyntaxError).
  • const arrays/objects are still mutable — freeze with Object.freeze (shallow) if you need immutable contents.

Recap

Choose variables by scope and mutability: const by default, let when you reassign, never var. Block scope keeps variables contained; lexical scope and the scope chain determine what's visible. Hoisting registers declarations before execution — var becomes undefined, functions become callable, and let/const sit in the temporal dead zone until declared, which catches use-before-declaration bugs. Understand binding-per-iteration and the loop bug disappears; prefer modules and strict mode and accidental globals disappear too.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.