JavaScript Enigmas: Cracking Closures

ยท

9 min read

JavaScript Enigmas: Cracking Closures

Closures, Closures, Closures !!!!๐Ÿค”

If you've ever felt puzzled by closures in JavaScript, you're in good company. You could be using closures every day in your code and not even know it! Yep, true story. Believe it or not, I once aced a closure-based interview question without fully understanding what closures were. However, understanding closures can significantly elevate your coding skills.

#What is Closure in Javascript ?

MDN says "A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function's scope from an inner function."

๐Ÿ‘€
I don't know about you but that definition is a little too tricky for me to understand.

Maybe we still can't define what closure is yet. We can at least agree that it is enclosed in some form of container called the Lexical environment, it also has something to do with functions bundled together with something about references.

What then is a lexical environment?
Clearly, we have to understand some basic concepts first before delving into closure itself as a concept.

Concepts around closure

Concept #1: Scoping / Lexical scoping.
Scoping simply means the locations a declared variable has "access" to and the lexical behind it tells you that the scope of a variable is determined based on its location in the source code.
In JavaScript, especially modern javascript, where you declare your variables matter. like, a lot! Variables defined in a function are not accessible outside of it, but they are accessible to functions nested/ inside that function.

const bookName = "readings";

function getAuthorName(){
const author = "Ajayi";
console.log(author, bookName); //"Ajayi readings"

function nestedFunction(){
    //has access to variable declared in the parent function
    // also has acess to global variables. E.G bookName
}
return author;
}

console.log(bookName) //"readings"
console.log(author) //Uncaught ReferenceError: author is not defined

As explained earlier, where you declare your variables matter in JavaScript because the scopes of these variables are created based on their location in the source code.
The rule is simple.

Variables defined with "let" or "const" within a block of code are only accessible within that block.

Hence, it is easy to understand why author and bookname in the example above are accessible within the function getAuthorName but trying to access author outside of the function throws an error.

Concept #2: Block Scoping, Functional Scoping, Global Scoping, and the Story of const ,let and var.
This is a topic that deserves its own stage, but let's do a quick fly-by.

  • Block Scoping: Variables declared with let and const stick to their block. It's just the modern way of doing things.

  • Functional Scoping: var is the OG here and it lives within the function it's declared in. It transcends the block scoping.

  • Global Scoping: Variables in the global scope are not declared within any block or functions, they can be accessed anywhere in the code.

  • const vs let vs var: Use const for constants, let for reassignable variables, and var... well, mostly avoid. ๐Ÿคทโ€โ™‚๏ธ

For a deep dive into this, check out this article that explains it all. Don't worry, I'll tackle this head-on in a future article!

Concept #3: Lexical environment
This is the tricky bit and it is quite theoretical in its own right. let me try to explain with a simple analogy.

Imagine you're a detective, and you're trying to find clues to solve a case. The place you'd look for these clues first is where the incident has happened (the crime scene), then you might find clues that lead you to look outside of that immediate environment. somewhere outside of the scene. The scene is the lexical environment. In JavaScript, a Lexical Environment exists for each block of code, each function, and yes, even for the global context that runs. Each lexical environment has two parts:

  1. Environment Record: This is the abstract location in which something has happened in code. E.G. a function call. It stores all the variables and declarations in that function.

  2. Reference to Outer Environment: Don't you love it when you're not confined within your space? same for our environment too. The environment likes to keep a reference to the Lexical Environment of the parent scope, parent parent scope, or global scope. You get the idea. It keeps a reference to the outer environments it will have access to.
    Here's a quick example to quench your cravings for some lines of code.

let city = "Gotham";

function batSignal() {
  let message = "Bat Signal activated in " + city;
  function displaySignal() {
    console.log(message); // "Bat Signal activated in Gotham"
  }
  displaySignal();
}

batSignal();

In this example, the function displaySignal() is nested inside batSignal() making it a Lexical Environment within another Lexical Environment - remember every function has its own Lexical Environment? When displaySignal() wants to print message, it first checks its own Environment Record (nope, not here!), and then the Reference to the Outer Environment kicks in, leading it to find message in batSignal()'s environment, a parent environment. ๐Ÿฆ‡ Kapish!!!

Perhaps now, MDN's definition will start to make sense. A closure, being a combination of a function with reference to.....

Concept #4: The Beast; Closures.
Remember when we talked about Lexical Environments? Good, because closures are like little minions that carry their own Lexical Environment wherever they go. Let me spill the tea.

A closure is a JavaScript function that remembers its outer variables even after the outer function has "returned" or after it has been "dissipated". In simpler terms, closures are functions that "remember" the environment in which they were created. ๐Ÿค”
Let's take our next illustration from Leetcode. The createCounter question.

//Given an integer n, return a counter function. This counter function 
//initially returns n and then returns 1 more than the previous
//value every subsequent time it is called (n, n + 1, n + 2, etc).
function createCounter(n) {
  return function() {
    return n++;
  };
}

// Example usage
const counter = createCounter(10);
console.log(counter()); // 10
console.log(counter()); // 11
console.log(counter()); // 12

Here, counter is a closure because every time you call it, it still knows the latest value of n and still has the ability to update it

How It Works ๐Ÿง™โ€โ™€๏ธ

  1. createCounter(n) returns a function (anonymous function). That function remembers the state of n even after createCounter has finished its execution. why? reference to outer lexical environments.

  2. Each time the returned function counter is called, it returns the current value of n and then increments it by 1 using the post-increment operator (n++)
    and that my friends, is how closures work. The returned function, which is the closure is like a minion that goes about with its own suitcase containing its own lexical environment and references to outer environments that scoping has allowed it access to.

The Nitty-Gritty: Specific Use-Cases for Closures

Use-Case #1: The Vault
JavaScript objects are wide open by default and can be manipulated at will. but you don't always want that. Sometimes, you want to create a vault that not everyone has access to and then keep your secret variables there. You see, before the introduction of classes and private variables in Javascript, Closures were a way of keeping variables private within a function so that only methods within that function have access to retrieving or changing their value.

function createBankAccount(initialDeposit) {
  let balance = initialDeposit;
  return {
    deposit: function(amount) {
      balance += amount;
      console.log(`Deposited ${amount}. Current balance: ${balance}`);
    },
    withdraw: function(amount) {
      if (amount > balance) {
        console.log('Insufficient funds');
      } else {
        balance -= amount;
        console.log(`Withdrew ${amount}. Current balance: ${balance}`);
      }
    }
  };
}

const myAccount = createBankAccount(1000);
myAccount.deposit(200); // You can do this!
myAccount.withdraw(500); // And this!
console.log(myAccount.balance); // But not this, Error!

In the code above, balance is a private variable that can't be accessed directly from outside the createBankAccount function. It can only be modified using the deposit and withdraw methods. why? aha! You should already know. because deposit and withdraw are closures. They are the only ones who still remember the value of balance even after the function createBankAccount has been executed and forgotten about.

Use-Case #2: Time Traveller; the secure timekeeper.

function launchSequence(start) {
  let count = start;
  return function() {
    console.log("T-minus " + count);
    count--;
  }
}

const countdown = launchSequence(10);

for (let i = 0; i <= 10; i++) {
  setTimeout(() => {
    countdown();
  }, i * 1000);
}

You can use closures to create timekeeping functions that cannot be tampered with from the outside world.
Here, countdown is a closure. It's carrying count from launchSequence() with it. Every time you call countdown(), it still knows what count is and updates it. countdown() carries its own supply of reference to count in its little briefcase

use-case #3: The Function Industry. We make food and we make functions.
Imagine you're operating a food truck. You've got your menu, but you also allow customers to customize their sandwiches. You need a way to easily produce variations of your menu items.

function sandwichMaker(fillings) {
  return function(breadType) {
    return `Here's your ${fillings} sandwich on ${breadType} bread!`;
  }
}

const makeChickenSandwich = sandwichMaker("chicken");
const makeVeggieSandwich = sandwichMaker("veggie");

console.log(makeChickenSandwich("sourdough")); // "Here's your chicken sandwich on sourdough bread!"
console.log(makeVeggieSandwich("rye")); // "Here's your veggie sandwich on rye bread!"

here, sandwichMaker is a function that takes a filling (like "chicken" or "veggie") and returns another function. The returned function then takes a type of bread, completes the sandwich, and serves it up!

So, every time a customer comes, you just have to call your pre-configured makeChickenSandwich or makeVeggieSandwich function with their choice of bread. Each function can be attached to its custom button. Now, you only have to hit the chicken or veggie button without having to worry about what could have been if you had only one sandwich button.

Use-Case #4: left Hook, right Hook, southpaw, useState Hook.
This is easily my favorite use case. I have been using the React's useState hook for years. I never cared about its implementation details. still don't.
however, you might begin to wonder, perhaps, the useState hook uses closure under the hood.

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
}

What is going on here?

  • State Preservation: useState gives you a variable (state) and a function to update it (setState). This state variable is preserved between function calls, much like a closure!

  • Functional Updates: This is where closures really kick in. When you update the state based on the previous state, you do something like this:

      setCounter(prevCounter => prevCounter + 1);
    

    Notice prevCounter? That's the previous state captured and passed into the function. setCounter must be a closure, folks!

Closing Prayer

Understanding closures in JavaScript is crucial for anyone looking to master the language. Closures offer a way to preserve state, encapsulate private variables, and enable dynamic function generation. Even if you're not keeping closures in your pocket for everyday use, knowing how they work demystifies a whole bunch of JavaScript quirks. Think of it as being able to see The Matrix. This understanding will prove invaluable across various JavaScript frameworks and libraries.

ย