Unlocking the Magic of JavaScript Closure

Unlocking the Magic of JavaScript Closure

In the realm of JavaScript programming, there exists a powerful concept that often confuses newcomers and even challenges experienced developers – closures. At first glance, closures might seem like an enigmatic puzzle, but they reveal a world of elegant solutions and enhanced code functionality once understood.

In this episode of Now You Know JavaScript, we'll take off on a journey to reveal the secrets of closures, clarify their inner workings, and uncover their practical applications. Whether you're a budding developer eager to grasp the core of closures or a seasoned coder seeking a deeper understanding, this blog will serve as your guide.

Fresh Starts Every Run: The Clean Slate Nature of Functions

As established in the previous episode, it's important to remember that each time a function is executed or invoked, it creates a completely new local memory – we call it also a variable environment. When we're done with the function's execution, this memory is wiped clean. If we run the function again, it doesn't hold on to any information from previous runs. Actually, it would be quite strange if it did – imagine running a function that adds two numbers and it somehow remembers what it added in the last run. We generally want functions to start fresh each time they run. Thankfully, that's the default behavior of functions – they don't retain any memory from past runs.

Yet What if we could somehow give a function a kind of permanent memory that sticks around? This would change the way we write code. And here's where things get intriguing – this idea of having a function with a permanent memory starts to take shape when we dive into the practice of returning functions from other functions.

From Normal to Super: Elevating Functions via Function Returns

Exploring the practice of returning functions from other functions adds a whole new layer to how functions work. It allows them to play different roles, acting as dynamic tools that not only perform tasks but also carry context with them. Let's delve into this fascinating concept to understand how it works.

Consider this example:

function createFunction(){
    function multiplyBy2(num){
        return num * 2;
    }
    return multiplyBy2;
}
const generatedFunc = createFunction();
const result = generatedFunc(3); // 6

Let's break down what's happening step by step:

  1. We define a function called createFunction and save it in the memory.

  2. We create a constant called generatedFunc and assign it the value that comes out of running createFunction. This value is essentially the multiplyBy2 function.

  3. We define another constant called result and set it to the result of running the generatedFunc with the argument 3 .

What's particularly interesting here is that generatedFunc and createFunction aren't tightly connected. While generatedFunc was indeed created by calling createFunction, after that initial creation, it stands on its own. This demonstrates how returning functions can lead to creating powerful and modular code constructs.

Full Circle: Invoking Functions in Their Birthplace

As we journey deeper into the world of JavaScript functions, we stumble upon an intriguing practice – calling functions from within the very place they were born. This concept might sound a bit unusual, but it holds the key to unlocking unique capabilities and crafting more flexible and efficient code.

function outer(){
    let counter = 0;
    function incrementCounter(){
        counter++;
    }
    incrementCounter();
}
outer();

And as usual, let's break down what's happening step by step:

  1. We define a function called outer and store it in global memory.

  2. We execute the outer function, which triggers the creation of a new execution context. Inside this context, we carry out the following:

    1. Define a variable called counter and assign it the value 0. This variable is stored in the local memory.

    2. Define another function called incrementCounter and save it in the local memory.

    3. We execute the incrementCounter function, resulting in the creation of yet another new execution context. Within this context:

      1. We increase the counter by 1 .

        But here's the intriguing part: what happens if the counter variable isn't found in the local memory of incrementCounter? In that case, we go out to the next layer – the local memory of the outer function.

This brings us to an interesting question: is this access to counter granted because incrementCounter is running inside outer, or is it due to the fact that incrementCounter is saved within the ongoing execution of outer? This mystery remains unsolved for now, but it's an essential piece in understanding closures and their behavior.

To answer this curious question, let's look at this example:

function outer(){
    let counter = 0;
    function incrementCounter(){
        counter++;
    }
    return incrementCounter;
}
const myNewFunction = outer();
myNewFunction();
myNewFunction();

In this scenario, we're doing something similar to before, but with a twist. Instead of directly using incrementCounter, we're now giving it a special job – to be returned from the outer function. This shift leads to an interesting exploration of what happens:

We start by calling outer. This triggers the creation of an execution context. When we hit the line counter++, we wonder if counter is available in the local memory for myNewFunction. The answer is no, but don't worry, we have a plan. We'll first check the global memory where all the code lives - going out one layer. However, even there, we can't find counter. This situation seems a bit confusing, doesn't it?

But here comes the fun part! When we returned incrementCounter from inside outer, it wasn't alone. It brought along a bag of memories – think of it as a backpack. Inside this backpack, it carried data from where it was born. This backpack is really handy when we're looking for things like counter.

As we proceed with running myNewFunction, and still can't find counter in the local memory, we remember about the backpack. We check there, and guess what? We find counter safely stored in it.

Now, let's not forget, once myNewFunction finishes its job and gets off the call stack, we're curious and call it again. And again, the local memory doesn't have counter. But no worries, our trusty backpack holds the answer.

This is where JavaScript shines! It doesn't only rely on the local memory that disappears after a function's job is done. It's like a clever trick – JavaScript attaches a long-lasting memory to the function, just like a backpack of memories. And that's what makes closures possible – they can remember things even after they're done running. This is the real beauty of JavaScript.

Backbags of Memories: How Functions Carry Context Beyond Execution

You might be curious about how exactly myNewFunction manages to carry all its surrounding data along with its definition. Well, here's the secret: when we created it, something special happened behind the scenes. It was stored in the memory, and it was given a sort of hidden property known as [[scope]], indicated by those double square brackets. This label is hidden away and acts as a connection to where all the nearby data resides. So, when we return the function definition, it's not just the code itself – it's also this [[scope]] label that's coming back.

One more important thing to remember is that we can't directly access this [[scope]] property in any other way. The only way to reach it is by running the function it's attached to. And inside that function, if the code is written in a certain way and it's searching for something that's not in its own local memory, it turns to its "backpack" – that is, the [[scope]] property – to locate what it needs.

There you have it – what we've been exploring in this section is the heart and soul of what we call a "closure." It's this remarkable ability of functions to carry not only their code but also a special memory backpack full of context. This memory backpack, attached with the [[scope]] property, allows functions to remember where they came from and what was happening around them at the time of their creation. This is what brings the concept of closure to life in JavaScript – this fusion of code and context, empowering us to create more intricate, powerful, and adaptable programs.

And that's a wrap for this episode! We've delved into the world of closures and discovered how they can make our code more powerful. Stay tuned for the next episode, where we'll continue our journey through the fascinating landscape of JavaScript. See you then!