Advanced JavaScript (ES6+)

Closures Deep Dive

13 min Lesson 11 of 40

Closures Deep Dive

Closures are one of the most powerful and fundamental concepts in JavaScript. Understanding closures will unlock advanced programming patterns and help you write more elegant, maintainable code. In this lesson, we'll explore what closures are, how they work, and how to use them effectively.

What Are Closures?

A closure is a function that has access to variables in its outer (enclosing) lexical scope, even after the outer function has returned. In simpler terms, a closure gives you access to an outer function's scope from an inner function.

Key Concept: Closures are created every time a function is created, at function creation time. They "remember" the environment in which they were created.

Basic Closure Example

Let's start with a simple example to understand the concept:

function outerFunction() { const outerVariable = "I'm from outer scope"; function innerFunction() { console.log(outerVariable); // Can access outerVariable } return innerFunction; } const myClosure = outerFunction(); myClosure(); // Output: I'm from outer scope

Even though outerFunction has finished executing, innerFunction still has access to outerVariable. This is a closure!

Lexical Scope and Closure

Closures are closely related to lexical scope - the idea that functions are executed using the variable scope that was in effect when they were defined, not when they are called.

function createCounter() { let count = 0; // Private variable return { increment: function() { count++; return count; }, decrement: function() { count--; return count; }, getCount: function() { return count; } }; } const counter = createCounter(); console.log(counter.increment()); // 1 console.log(counter.increment()); // 2 console.log(counter.decrement()); // 1 console.log(counter.getCount()); // 1 // Cannot access count directly console.log(counter.count); // undefined
Tip: The count variable is private and can only be accessed through the returned methods. This is encapsulation in JavaScript!

Private Variables with Closures

Closures enable us to create private variables that cannot be accessed from outside the function:

function createBankAccount(initialBalance) { let balance = initialBalance; // Private variable return { deposit: function(amount) { if (amount > 0) { balance += amount; return `Deposited: $${amount}. New balance: $${balance}`; } return "Invalid amount"; }, withdraw: function(amount) { if (amount > 0 && amount <= balance) { balance -= amount; return `Withdrawn: $${amount}. New balance: $${balance}`; } return "Invalid amount or insufficient funds"; }, getBalance: function() { return `Current balance: $${balance}`; } }; } const myAccount = createBankAccount(1000); console.log(myAccount.deposit(500)); // Deposited: $500. New balance: $1500 console.log(myAccount.withdraw(200)); // Withdrawn: $200. New balance: $1300 console.log(myAccount.getBalance()); // Current balance: $1300 // Cannot directly access balance console.log(myAccount.balance); // undefined

Module Pattern with Closures

Closures are the foundation of the module pattern, which allows you to create modules with private and public members:

const calculator = (function() { // Private variables and functions let history = []; function addToHistory(operation) { history.push(operation); } // Public API return { add: function(a, b) { const result = a + b; addToHistory(`${a} + ${b} = ${result}`); return result; }, subtract: function(a, b) { const result = a - b; addToHistory(`${a} - ${b} = ${result}`); return result; }, getHistory: function() { return history.slice(); // Return copy of history }, clearHistory: function() { history = []; } }; })(); console.log(calculator.add(5, 3)); // 8 console.log(calculator.subtract(10, 4)); // 6 console.log(calculator.getHistory()); // ["5 + 3 = 8", "10 - 4 = 6"]

Event Handlers and Closures

Closures are extremely useful when working with event handlers:

function setupButton(buttonId, message) { const button = document.getElementById(buttonId); let clickCount = 0; button.addEventListener('click', function() { clickCount++; console.log(`${message} - Clicked ${clickCount} times`); }); } setupButton('btn1', 'Button 1'); setupButton('btn2', 'Button 2'); // Each button maintains its own clickCount via closure

Closures in Loops

A common pitfall with closures occurs in loops. Here's the problem and solution:

// ❌ Problem: All functions reference the same i for (var i = 1; i <= 3; i++) { setTimeout(function() { console.log(i); // Prints 4, 4, 4 }, i * 1000); } // ✅ Solution 1: Use let (block scope) for (let i = 1; i <= 3; i++) { setTimeout(function() { console.log(i); // Prints 1, 2, 3 }, i * 1000); } // ✅ Solution 2: Use IIFE to create closure for (var i = 1; i <= 3; i++) { (function(num) { setTimeout(function() { console.log(num); // Prints 1, 2, 3 }, num * 1000); })(i); }
Warning: When using var in loops with asynchronous operations, remember that var is function-scoped, not block-scoped. Use let or create a closure with an IIFE.

Function Factory Pattern

Closures allow you to create function factories that generate customized functions:

function createMultiplier(multiplier) { return function(number) { return number * multiplier; }; } const double = createMultiplier(2); const triple = createMultiplier(3); const quadruple = createMultiplier(4); console.log(double(5)); // 10 console.log(triple(5)); // 15 console.log(quadruple(5)); // 20 // More practical example function createGreeting(greeting) { return function(name) { return `${greeting}, ${name}!`; }; } const sayHello = createGreeting("Hello"); const sayHi = createGreeting("Hi"); const sayWelcome = createGreeting("Welcome"); console.log(sayHello("John")); // Hello, John! console.log(sayHi("Sarah")); // Hi, Sarah! console.log(sayWelcome("Mike")); // Welcome, Mike!

Memory Considerations

Closures keep references to their outer scope, which can impact memory:

function createHeavyObject() { const largeData = new Array(1000000).fill("data"); return { // ❌ This keeps largeData in memory getData: function() { return largeData; } }; } // ✅ Better: Only keep what you need function createOptimizedObject() { const largeData = new Array(1000000).fill("data"); const summary = largeData.length; return { getSummary: function() { return summary; // Only keeps the number, not the array } }; }
Best Practice: Be mindful of what variables you're closing over. If you don't need the entire object or array, extract only what you need to avoid unnecessary memory usage.

Common Closure Patterns

Here are some practical patterns using closures:

// 1. Memoization function memoize(fn) { const cache = {}; return function(...args) { const key = JSON.stringify(args); if (key in cache) { console.log('Returning cached result'); return cache[key]; } const result = fn(...args); cache[key] = result; return result; }; } const expensiveCalculation = memoize((n) => { console.log('Calculating...'); return n * n; }); console.log(expensiveCalculation(5)); // Calculating... 25 console.log(expensiveCalculation(5)); // Returning cached result 25 // 2. Once Function function once(fn) { let called = false; let result; return function(...args) { if (!called) { called = true; result = fn(...args); } return result; }; } const initialize = once(() => { console.log('Initializing...'); return "Initialized"; }); console.log(initialize()); // Initializing... Initialized console.log(initialize()); // Initialized (doesn't run again) // 3. Debounce function debounce(fn, delay) { let timeoutId; return function(...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; } const debouncedSearch = debounce((query) => { console.log(`Searching for: ${query}`); }, 500); // Only searches after 500ms of no typing

Practice Exercise:

Challenge: Create a createTimer() function that returns an object with methods to start, stop, and get elapsed time. The elapsed time should be private and only accessible through the methods.

Solution:

function createTimer() { let startTime = null; let elapsedTime = 0; let isRunning = false; return { start: function() { if (!isRunning) { startTime = Date.now() - elapsedTime; isRunning = true; return "Timer started"; } return "Timer already running"; }, stop: function() { if (isRunning) { elapsedTime = Date.now() - startTime; isRunning = false; return "Timer stopped"; } return "Timer not running"; }, getTime: function() { if (isRunning) { return Date.now() - startTime; } return elapsedTime; }, reset: function() { startTime = null; elapsedTime = 0; isRunning = false; return "Timer reset"; } }; } const timer = createTimer(); console.log(timer.start()); // Timer started setTimeout(() => { console.log(timer.getTime()); // ~2000ms console.log(timer.stop()); // Timer stopped }, 2000);

Summary

In this lesson, you learned:

  • Closures are functions that remember their lexical scope
  • Closures enable private variables and data encapsulation
  • The module pattern uses closures to create private and public members
  • Closures are essential for event handlers and callbacks
  • Common pitfalls with closures in loops and how to avoid them
  • Function factories create customized functions using closures
  • Memory considerations when working with closures
  • Practical closure patterns: memoization, once, debounce
Next Up: In the next lesson, we'll explore IIFE (Immediately Invoked Function Expressions) and the Module Pattern in depth!

ES
Edrees Salih
18 hours ago

We are still cooking the magic in the way!