Skip to content
JavaScript Closures & Scope Explained — Lexical Scoping, Hoisting & this Binding

JavaScript Closures & Scope Explained — Lexical Scoping, Hoisting & this Binding

DodaTech Updated Jun 15, 2026 9 min read

JavaScript closures and scope are the foundation of how variables are accessed and preserved in JavaScript. A closure is a function that “remembers” the variables from its outer scope even after that outer function has finished running. The Doda Browser uses closures for its event handlers, callback functions, and module system. The Durga Antivirus Pro dashboard uses closures for debounced search inputs and private state in its scanner modules.

What You’ll Learn

  • Lexical scoping — how JavaScript resolves variable names
  • Closure patterns — module pattern, factory functions, partial application
  • IIFE (Immediately Invoked Function Expression)
  • Hoisting — how var, let, const, and function declarations behave
  • The temporal dead zone (TDZ) for let and const
  • this binding rules — default, implicit, explicit, arrow functions
  • Building a counter with closure

Why Closures and Scope Matter

Without closures, every function would start fresh with no memory of its context. Closures enable data privacy (private variables in modules), function factories (functions that create functions), and event-driven programming (callbacks that remember their environment). Scope rules determine what variables your code can access — mastering them prevents some of the most common JavaScript bugs.

Learning Path

    flowchart LR
  A[JS Functions & Scope] --> B[JS Objects & Prototypes]
  B --> C[Closures & Scope Deep Dive]
  C --> D[Design Patterns]
  C --> E[Performance]
  C --> F[You Are Here]
  
Prerequisites: You should understand JavaScript functions, variables, and basic scope concepts. This tutorial deepens those fundamentals.

Lexical Scoping

JavaScript uses lexical (static) scoping — a variable’s scope is determined by where it’s declared in the source code, not where it’s called:

const global = 'I am global';

function outer() {
  const outerVar = 'I am in outer';

  function inner() {
    const innerVar = 'I am in inner';
    console.log(global);    // 'I am global'
    console.log(outerVar);  // 'I am in outer'
    console.log(innerVar);  // 'I am in inner'
  }

  inner();
  console.log(innerVar); // ReferenceError — not in scope
}

outer();

How scope resolution works: When JavaScript encounters a variable, it looks up the scope chain — current function scope, then outer function scope, then global scope. This is why inner() can access outerVar but outer() cannot access innerVar.

What Is a Closure?

A closure is created when a function retains access to its outer lexical scope even after the outer function has returned:

function createCounter() {
  let count = 0;  // Private variable

  return function() {
    count++;      // Inner function "closes over" count
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

How it works: When createCounter returns, its local variables would normally be garbage-collected. But the returned function holds a reference to count through the closure, so JavaScript keeps it alive. The counter function “remembers” its birth environment.

Analogy: A closure is like a backpack. The function leaves its birthplace carrying a backpack with all the variables it needs. Even after the birthplace is gone, the function still has its backpack.

Closure Patterns

Module Pattern (Private Variables)

function createUserStore() {
  const users = [];  // Private — can't be accessed from outside

  return {
    add(name) {
      users.push({ name, id: Date.now() });
      console.log(`${name} added. Total: ${users.length}`);
    },
    getAll() {
      return [...users]; // Return a copy to prevent mutation
    },
    find(name) {
      return users.find(u => u.name === name);
    }
  };
}

const store = createUserStore();
store.add('Alice');        // Alice added. Total: 1
store.add('Bob');          // Bob added. Total: 2
console.log(store.users);  // undefined — private!
console.log(store.getAll().length); // 2

Factory Function

function createLogger(prefix) {
  return function(message) {
    console.log(`[${prefix}] ${message}`);
  };
}

const infoLogger = createLogger('INFO');
const errorLogger = createLogger('ERROR');

infoLogger('Server started');  // [INFO] Server started
errorLogger('Connection failed'); // [ERROR] Connection failed

Partial Application

function multiply(a, b) {
  return a * b;
}

function partial(fn, ...fixedArgs) {
  return function(...remainingArgs) {
    return fn(...fixedArgs, ...remainingArgs);
  };
}

const double = partial(multiply, 2);
const triple = partial(multiply, 3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

IIFE (Immediately Invoked Function Expression)

An IIFE runs immediately and creates a private scope — useful before ES6 modules existed:

(function() {
  const privateVar = 'Secret';
  console.log('IIFE runs immediately');
})();
// privateVar is not accessible here

// With parameters
const result = (function(a, b) {
  return a + b;
})(3, 4);

console.log(result); // 7

Modern use: IIFEs are rarely needed now that ES6 modules provide proper scoping, but you’ll still see them in legacy code and some build tools.

Hoisting

JavaScript moves declarations to the top of their scope during compilation:

// Function declarations are hoisted — this works
sayHello(); // 'Hello!'
function sayHello() {
  console.log('Hello!');
}

// var is hoisted but initialized as undefined
console.log(myVar); // undefined (not ReferenceError!)
var myVar = 5;
console.log(myVar); // 5

// let/const are hoisted but NOT initialized (TDZ)
console.log(myLet); // ReferenceError: Cannot access before initialization
let myLet = 10;

Temporal Dead Zone (TDZ)

The TDZ is the period between entering a scope and the variable’s declaration where let and const variables exist but cannot be accessed:

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

// Practical example
function test() {
  console.log(typeof value); // undefined (TDZ doesn't apply to typeof...)
  let value = 10;
}
test();

Anticipating confusion: The TDZ exists because let and const don’t get initialized to undefined like var does. They exist in the scope but have no value until the declaration line is reached.

var vs let vs const

// var — function scoped
function example() {
  if (true) {
    var x = 10; // Scoped to function, not block
  }
  console.log(x); // 10 — accessible outside the block!
}

// let — block scoped
function example() {
  if (true) {
    let y = 20; // Scoped to this block only
  }
  console.log(y); // ReferenceError
}

// const — block scoped, cannot be reassigned
const PI = 3.14159;
// PI = 3; // TypeError: Assignment to constant variable

const obj = { name: 'Alice' };
obj.name = 'Bob'; // Allowed — const prevents reassignment, not mutation
Featurevarletconst
ScopeFunctionBlockBlock
HoistedYes (undefined)Yes (TDZ)Yes (TDZ)
ReassignableYesYesNo
RedeclarableYesNoNo

Best practice: Use const by default, let when you need reassignment, and never use var.

this Binding

The value of this depends on how a function is called, not where it’s defined:

// 1. Default binding — window (or undefined in strict mode)
function showThis() {
  'use strict';
  console.log(this); // undefined
}

// 2. Implicit binding — the object before the dot
const user = {
  name: 'Alice',
  greet() {
    console.log(`Hello, ${this.name}`);
  }
};
user.greet(); // 'Hello, Alice'

// 3. Explicit binding — call, apply, bind
function greet() {
  console.log(`Hello, ${this.name}`);
}
const person = { name: 'Bob' };
greet.call(person);  // 'Hello, Bob'
greet.apply(person); // 'Hello, Bob'
const boundGreet = greet.bind(person);
boundGreet();        // 'Hello, Bob'

// 4. Arrow functions — inherit this from enclosing scope
const obj = {
  name: 'Charlie',
  regular: function() {
    console.log(this.name); // 'Charlie'
  },
  arrow: () => {
    console.log(this.name); // undefined (this is window/global)
  }
};

The arrow function rule: Arrow functions don’t have their own this. They use this from the enclosing non-arrow function. This makes them ideal for callbacks:

class Timer {
  constructor() {
    this.seconds = 0;
    // ❌ Regular function would lose `this`
    // setInterval(function() { this.seconds++; }, 1000);
    // ✅ Arrow function captures `this` from constructor
    setInterval(() => { this.seconds++; }, 1000);
  }
}

Common Mistakes

1. Creating closures in loops (old-style)

// ❌ All callbacks log 3, not 0, 1, 2
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3

// ✅ Use let (block scoped)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100);
}
// Output: 0, 1, 2

2. Confusing lexical scope with this binding

this is NOT determined by lexical scope — it’s determined by how the function is called. Arrow functions are the exception (they use lexical this).

3. Assuming var is block-scoped

if (true) {
  var x = 5;
}
console.log(x); // 5 — not blocked!

Fix: Use let or const.

4. Forgetting that const doesn’t mean immutable

const arr = [1, 2, 3];
arr.push(4); // Allowed — arr is still the same reference

5. Losing this in callbacks

button.addEventListener('click', this.handleClick); // this is wrong

Fix: Use .bind(this), an arrow function, or store this as a variable.

Practice Questions

  1. What is a closure? A function that retains access to its outer scope variables even after the outer function has returned.

  2. What’s the difference between var, let, and const? var is function-scoped and hoisted with undefined. let and const are block-scoped and have a temporal dead zone. const cannot be reassigned.

  3. How does this behave differently in arrow functions? Arrow functions don’t have their own this — they inherit it from the enclosing non-arrow function.

  4. What is the temporal dead zone? The period between entering a scope and the variable’s declaration where let/const variables exist but can’t be accessed.

Challenge: Write a memoize(fn) function that uses closures to cache expensive function results, returning cached results for repeated calls with the same arguments.

Mini Project: Counter with Closure

function createAdvancedCounter(start = 0) {
  let count = start;
  const history = [];

  return {
    increment(step = 1) {
      count += step;
      history.push({ action: 'increment', step, timestamp: Date.now() });
      return count;
    },
    decrement(step = 1) {
      count -= step;
      history.push({ action: 'decrement', step, timestamp: Date.now() });
      return count;
    },
    reset() {
      count = start;
      history.push({ action: 'reset', timestamp: Date.now() });
      return count;
    },
    getCount() {
      return count;
    },
    getHistory() {
      return [...history];
    }
  };
}

const counter = createAdvancedCounter(10);
console.log(counter.increment());    // 11
console.log(counter.increment(5));   // 16
console.log(counter.decrement(3));   // 13
console.log(counter.getCount());     // 13
console.log(counter.getHistory().length); // 3

FAQ

What is a closure in JavaScript?
A closure is a function that “remembers” the variables from its outer scope even after the outer function has finished executing. It’s created automatically every time a function is defined inside another function.
What is hoisting?
JavaScript’s behavior of moving variable and function declarations to the top of their scope before execution. Function declarations are fully hoisted; var is hoisted with undefined; let/const are hoisted but uninitialized (TDZ).
What is the temporal dead zone?
The TDZ is the time between entering a block scope and the declaration of a let or const variable. Accessing the variable during this period throws a ReferenceError.
When should I use an arrow function?
Use arrow functions for callbacks, array methods (map, filter, reduce), and when you need to preserve the outer this. Don’t use them for object methods or constructors.
How do I fix the “this is undefined” bug?
Check your function type — if it’s a regular function used as a callback, use .bind(), an arrow function, or a closure variable like const self = this.

What’s Next

LessonDescription
JavaScript HomeBack to the JavaScript hub
https://tutorials.dodatech.com/programming-languages/javascript/js-functions/Functions & Scope basics
https://tutorials.dodatech.com/programming-languages/javascript/js-design-patterns/Design patterns in JavaScript
https://tutorials.dodatech.com/programming-languages/javascript/js-performance/Performance optimization
Node.jsClosures in Node.js async patterns

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

What’s Next

Congratulations on completing this JavaScript Closures & Scope tutorial! Here’s where to go from here:

  • Practice daily — Consistency is more important than long study sessions
  • Build a project — Apply what you learned by building something real
  • Explore related topics — Check out other tutorials in the same category
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro