Learn About Context, Scope, and Closures
Learning Objectives
After completing this unit, you’ll be able to:
- Identify how variables are scoped in JavaScript.
- Describe how
this
changes depending on where a function is called. - Use closures to capture references to variables in functions.
A critical piece of understanding any programming language is understanding the availability of variables, how state is maintained, and how to access that state.
In JavaScript the availability and visibility of variables is referred to as scope. Scope is determined by where a variable is declared.
Context is the state of the current execution of code. It is accessed through the this
pointer.
Variable Scope
Variables in JavaScript are declared using the var
, let
, or const
keywords. Where you call the keyword dictates the scope of variable being created.
Understanding the difference between these three comes down to two factors: assignment mutability and supporting nonfunction block scope. We covered assignment mutability in the first unit of this module. It’s time to discuss scope.
Fun with Scope
The block of code where a variable or argument is declared determines its scope. But var does not recognise nonfunction blocks of code. This means calling var in an if block or a loop block will assign the variable to the scope of the nearest enclosing function. This feature is called declaration hoisting.
When using let
or const
, an argument’s or a variable’s scope is always the actual block in which it is declared. There is a classic thought exercise to show this.
function countToThree() {
// i is in the scope of the countToThree function
for (var i = 0; i < 3; i++){
console.log(i); // iteration 1: 0
// iteration 2: 1
// iteration 3: 2
}
console.log(i); // What is this?
}
The console.log
output inside the for
loop is unsurprising, outputting the value of i
for each iteration. What might be more surprising is the final console.log
statement, which outputs 3
. You might have expected an error, since i
is declared inside what you would assume is scope of the for
loop. But with hoisting, i
actually belongs to countToThree
’s scope.
While not necessarily bad, hoisting is often misunderstood and can create variable leakage or cause accidental overwrites if a variable is redeclared in a code block. To address these misunderstandings let
and const
were added to the language to create variables with block-level scope. Let’s revisit the thought exercise.
for (let j = 0; j < 3; j++){
console.log(j); // 0
// 1
// 2
}
console.log(j); // error
By substituting let
for var
, we now have a variable that exists only in the context of the for
loop. Trying to access it after the loop has closed gives us an error.
Context and this
As we explored, JavaScript revolves around objects. When a function is invoked, there is always an object container around that function. This object container is its context and the this
keyword points to that context. So context is not set when a function is declared, but rather where the function is invoked.
Because functions can be passed around between objects, what this
points to can change.
Say, for instance, this JavaScript object.
var obj = {
aValue: 0,
increment: function(incrementBy) {
this.aValue = this.aValue + incrementBy;
}
}
If you then access the increment function, it works as expected.
obj.increment(2);
console.log(obj.aValue); // 2
But let’s assign that function to another variable and see how it works.
//assign function to variable
var newIncrement = obj.increment;
//now invoke through the new pointer
newIncrement(2);
console.log(obj.aValue); // still 2 not 4
By assigning the variable to newIncrement
, the function now is executed in a different context. Specifically, in this case, in the global context.
The Global Object
When JavaScript is executed without any containing object that you write as a developer, it runs in a global object. For this reason, functions invoked there are said to be running in the global context, which means that accessing this
will point there.
In a browser, the global context is the window
object. You can test this easily by running the following in your browser developer tools.
this === window; // true
In the increment
example, assigning the increment
function to the newIncrement
variable moves the context where it is invoked: to the global object. This is easy to demonstrate.
console.log(this.aValue); // NaN
console.log(window.aValue); // NaN
console.log(typeof window.aValue); // number
When we attempt to assign to this.aValue
with the new context, the mutable nature of JavaScript objects comes into play. A new uninitialized aValue
property is added to this
. Performing math on an uninitialized variable fails, thus the NaN
value. But we can see that aValue
exists on window
, and is indeed a number.
Context with Your Objects
In the increment
example, as long as the increment
function is invoked using obj
with the dot notation, this
points to obj
. Or, generally speaking, when calling a function as someObject.function()
the thing to the left of the dot is the context in which that function is invoked.
Think about the Bike
example. The Bike
constructor defines several properties with the this
reference. It also has functions assigned to its prototype that reference this
.
const Bike = function(frontIndex, rearIndex){
this.frontGearIndex = frontIndex || 0;
this.rearGearIndex = rearIndex || 0;
...
}
...
Bike.prototype.calculateGearRatio = function(){
let front = this.transmission.frontGearTeeth[this.frontGearIndex],
rear = this.transmission.rearGearTeeth[this.rearGearIndex];
if (front && rear) {
return (front / rear) ;
} else {
return 0;
}
};
We then call Bike
with the new
keyword.
const bike = new Bike(1,2);
console.log(bike.frontGearIndex); // 1
console.log(bike.rearGearIndex); // 2
This looks like we’re invoking the Bike
constructor in the global context. However, the new
keyword shifts the context (and the this
pointer) to the new object on the left side of the assignment.
When we invoke any of the functions they are now members of the bike
object, so they use that as the containing context.
let gearRatio = bike.calculateGearRatio();
console.log(gearRatio); // 3
It’s easy to invoke a constructor the wrong way. Here’s where things can fall apart.
const badBike = Bike(1,2);
console.log(badBike.frontGearIndex); // error
console.log(window.frontGearIndex); // 1
When you forget to use new
, Bike
is called like any other function and that crucial shift of this
from window
to the newly created object fails. Object mutability steps in and the frontGearIndex
property is added to window
instead.
Closures
When a function is declared, it holds a reference to any variables or arguments declared within it, and any variables it references in the scope that it is contained within. This combination of its variables and arguments along with local variables and arguments from its containing scope is called a closure.
Consider this function, and the function it returns.
const greetingMaker = function(greeting){
return function(whoGreeting){
return greeting + ", " + whoGreeting + "!";
}
}
const greetingHello = greetingMaker("Hello");
const greetingBonjour = greetingMaker("Bonjour");
const greetingCiao = greetingMaker("Ciao");
console.log(greetingHello("Gemma")); // Hello, Gemma!
console.log(greetingBonjour("Fabien")); // Bonjour, Fabien!
console.log(greetingCiao("Emanuela")); // Ciao, Emanuela!
When greetingMaker
is invoked, we might normally imagine its greeting
argument to last only for the life of it being called.
But the returned function keeps a reference to the greeting
argument in greetingMaker
’s scope. So that finally when it is invoked through greetingHello
/Bonjour
/Ciao
, it’s still accessible.
Grasping closures is also an essential part of understanding and using the language.