Skip to content
elephantoo

Scope, hoisting & closures

Lesson 8 of 34 16 min read

Block, function and global scope, hoisting and the temporal dead zone, and closures.


Scope answers the question "where can I use this variable?". Hoisting explains what JavaScript does with declarations before running your code. Closures — functions remembering their surroundings — follow naturally and power everything from counters to React hooks.

Three levels of scope#

JavaScript
const appName = "Elephantoo"; // global (module) scope

function showLesson() {
  const lesson = "Closures"; // function scope

  if (true) {
    const note = "block scoped"; // block scope
    console.log(appName, lesson, note); // all visible here
  }

  // console.log(note); // ReferenceError: note is not defined
  return lesson;
}

console.log(showLesson());
// console.log(lesson); // ReferenceError
Output
Elephantoo Closures block scoped
Closures
  • Block scope: let and const live inside the nearest { } — an if, loop or plain block.
  • Function scope: variables declared in a function are private to it. var only respects function boundaries, not blocks.
  • Global scope: top-level variables. In a browser <script> (not a module), top-level var even becomes a property of window. ES modules have their own top-level scope, which is one reason to use them.

The scope chain

Inner scopes can see outer ones, but not the other way round. JavaScript looks up a name in the current scope, then the enclosing one, and so on out to the global scope:

JavaScript
const level = "global";

function outer() {
  const level = "outer";
  function inner() {
    console.log(level); // finds the nearest one: "outer"
  }
  inner();
}

outer();
console.log(level); // "global"

Declaring a variable with the same name in an inner scope shadows the outer one. It's legal but can be confusing — prefer distinct names.

This is called lexical scope: what a function can see is decided by where it's written, not where it's called from.

Hoisting#

Before running code, JavaScript scans each scope and sets up its declarations. That's hoisting, and each kind of declaration behaves differently:

DeclarationHoisted?Before its line…
function f() {}FullyCan be called ✅
var xYes, initialised to undefinedReads give undefined 😬
let x / const xYes, but uninitialisedReferenceError (TDZ)
class C {}Like letReferenceError
const f = () => {}Like constReferenceError
JavaScript
console.log(sayHi()); // "Hi!" – function declarations are fully hoisted
function sayHi() {
  return "Hi!";
}

console.log(oldStyle); // undefined – var is hoisted and set to undefined
var oldStyle = "var";

try {
  console.log(modern);
} catch (err) {
  console.log(err.name + ": " + err.message);
}
let modern = "let";
Output
Hi!
undefined
ReferenceError: Cannot access 'modern' before initialization

The zone between the start of the scope and the let/const line is the temporal dead zone (TDZ). It's a feature: an error is much better than a silent undefined.

Closures#

A closure is a function together with the variables it captured from where it was created. Every function in JavaScript is a closure:

JavaScript
function makeCounter() {
  let count = 0; // private to this counter
  return function () {
    count += 1;
    return count;
  };
}

const counterA = makeCounter();
const counterB = makeCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 – B has its own separate count
console.log(counterA()); // 3

makeCounter has finished running, yet the returned function still reads and updates count. Each call to makeCounter creates a fresh count, so the counters are independent. Nothing outside can touch count directly — closures give you private state.

Practical closure: a module with private data

JavaScript
function createWallet(initial = 0) {
  let balance = initial;

  return {
    deposit(amount) {
      if (amount <= 0) throw new Error("Deposit must be positive");
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) return "Insufficient funds";
      balance -= amount;
      return balance;
    },
    get balance() {
      return balance;
    },
  };
}

const wallet = createWallet(500);
wallet.deposit(250);
console.log(wallet.withdraw(1000)); // Insufficient funds
console.log(wallet.withdraw(100));  // 650
console.log(wallet.balance);        // 650
// wallet.balance = 1_000_000;      // no setter: ignored, or a TypeError in strict mode/modules

Practical closure: configuring functions

JavaScript
function makeFormatter(currency) {
  const fmt = new Intl.NumberFormat("en-IN", { style: "currency", currency });
  return (amount) => fmt.format(amount);
}

const inr = makeFormatter("INR");
const usd = makeFormatter("USD");
console.log(inr(149999)); // ₹1,49,999.00
console.log(usd(20));     // $20.00

The expensive formatter is created once and reused by the closure.

Practical closure: run only once

JavaScript
function once(fn) {
  let called = false;
  let result;
  return (...args) => {
    if (!called) {
      called = true;
      result = fn(...args);
    }
    return result;
  };
}

const init = once(() => {
  console.log("Initialising…");
  return 42;
});
console.log(init()); // Initialising… then 42
console.log(init()); // 42 – the function didn't run again

The classic loop trap#

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

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let:", j), 0);
}
Output
var: 3
var: 3
var: 3
let: 0
let: 1
let: 2

With var there's one i shared by all callbacks, and by the time they run the loop has finished and i is 3. let creates a new binding for each iteration, so each callback closes over its own value. Yet another reason to avoid var.

Closures and memory#

A closure keeps its captured variables alive as long as the function itself is reachable. That's usually exactly what you want, but a long-lived closure (for example, an event listener that's never removed) that captures a huge array keeps that array in memory. Remove listeners and clear timers you no longer need.

Common mistakes#

  • Using a let/const variable before its declaration (TDZ error).
  • Accidentally creating a global by assigning to an undeclared name (total = 5). Modules and "use strict" turn this into an error — another reason to use modules.
  • Expecting inner variables to be visible outside their block.
  • Shadowing an outer variable and then being confused about which one changed.

What's next#

With functions and scope under your belt, it's time to organise data with arrays and objects.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What happens when you run console.log(x); let x = 1;?

  2. 2.What is a closure?

  3. 3.What does this print? for (var i = 0; i < 3; i++) setTimeout(() => console.log(i), 0);

Finished reading?

Mark this lesson complete to track your progress.