The scope of a variable is a way of describing where it can be accessed within code. [1] You're probably already aware of this, even if you've never heard of scope before.
function funcOne (){
let a = 5;
console.log(a);
};
funcOne(); // 5
console.log(a) // ReferenceError: a is undefined
Here, we declare a variable, a
, within funcOne()
. When we attempt to log a
within funcOne()
we can see the value of a
. However, when we attempt to access a
outside of funcOne()
we get a ReferenceError
. That is to say, it seems that a
only exists within funcOne()
, and a
ceases to exist once funcOne()
has finished executing.
To make things a bit more interesting, we could declare another variable, b
, in the top level of our code.
let b = "I can be accessed from anywhere!";
function funcOne(){
let a = 5;
console.log(b);
}
funcOne(); // "I can be accessed from anywhere"
console.log(a); // ReferenceError: a is not defined
Even though b
is not declared inside funcOne()
, we can successfully log out the value of b
within it. Because we can access variable b
within funcOne()
, we say that b
is within the scope of funcOne()
. Contrarily, as we cannot access a
outside of funcOne()
, we say that a
is only within the scope of funcOne()
(or, that a
is "scoped" to funcOne()
).
The scope of a variable describes where the variable can be accessed from. Therefore, the scope of a function describes which variables it has access to. [2]
But how do we actually define where certain scopes begin and end? Why can we access b
within funcOne()
, yet we cannot access a
from outside funcOne()
? Well this depends entirely on how we declare the variable!
As you may know, in JavaScript we have three options to declare a new variable: using the let
, const
, or var
keywords. [3]
let x = 5;
const name = "Josh";
var isCool = true;
const
, as its name suggests, is used to declare constants. This means that variables declared with const
cannot later be reassigned to a different value (they can still be mutated, such as pushing a value into an array declared using const
, however we will leave that for another day!). [4]
const favouriteDrink = "Water";
favouriteDrink = "Hot Chocolate"; // Not allowed!
let age = 23;
age = 24; // Is allowed!
var isCool = true;
isCool = false; // Is allowed!
However, what about let
vs var
? The biggest difference between these is how the variables they declare get scoped.
Variables declared using
let
(andconst
) are block-scoped, those declared withvar
are function-scoped. [4][5][6]
Okay, so we seem to have answered the question of "How do we define the scope of a variable?" by replacing this with "What does it mean to be block-scoped or function-scoped?".
We can split our code into specific blocks. In JavaScript, these are denoted by a set of curly braces { }
surrounding some code. [7]
{
// This is one block
};
{
// This is another block
};
We see these blocks appear all the time when using loops, conditions, and functions.
for (let i = 0; i < 5; i++){
// A block
};
if(/* condition */){
// Another new block
};
const func = () => {
// Oh look, another new block!
};
When we declare a block-scoped variable, it becomes tied to its surrounding block. That is to say, it can only be accessed from within the set of curly braces you declare it in. If we attempt to access a variable in one block that is defined in another, JS will be unable to find it and throw a ReferenceError
. However, we can nest blocks inside each other. [7]
if (/* condition */){
// In block 1
if (/* second condition */){
// In block 1 AND block 2
// etc...
};
};
Even though we are entering a new block (the second if
statement), we are still inside the first block as we haven't left those curly braces that defined block 1. This means, if we declare a variable in block 1, we can access it in block 2 (this is what was happening with a
and b
above!).
if (/* condition */){
let a = 5;
console.log(a); // 5
if (/* second condition */){
console.log(a); // 5
};
};
To put this in a more relational way, we can imagine this interaction as a conversation between the block and the scope-manager for that block. When we call a variable within a block, the block asks the scope manager "Heya, they're trying to access a variable called a
, do we declare that anywhere in this block?". The scope-manager then responds "No, we don't - but I'll ask the guy above me", and so they then ask the scope-manager for the next outer block "Heya, we need to know who a
is referring to, do you declare a
here?". This chain continues until we get to the global scope (see below) - at which point, if a
is still not defined, JavaScript throws a ReferenceError
.
Note, however, that this is a one-way interaction. If we declare a variable in block 2, it is inaccessible by block 1 as we have to leave the curly braces that define block 2 to re-enter block 1. [8]
if (/* condition */){
// block 1
if (/* second condition */){
// block 1 AND block 2
let x = 5;
};
console.log(x) // ReferenceError: x is not defined.
};
A block is defined by a set of curly braces surrounding some code (i.e. a function declaration, a loop, etc). We can nest blocks inside other blocks, however this is a one-way street: nested blocks can access variables declared in their parent blocks, but parent blocks cannot access variables declared their nested blocks.
That about covers block-scoped variables (remember, these are variables declared using const
or let
), however what about function-scoped variables?
The scope of a function-scoped variable is similar to block-scoped, however it is more broad. Instead of a variable being tied to the block that it is declared in, it is instead tied to the function it is declared in. Therefore, a variable declared using var
becomes available to the entire function it is declared within. [6]
function thisIsAFunction(){
if (/* some condition */){
var x = 5;
let y = 1
}
console.log(x) // 5
console.log(y) // ReferenceError
}
Above, we declare x
using the var
keyword within the conditional block, and so it becomes available for the entire function to use. However, when we declare y
using let
, this is only accessible within the conditional block (throws a ReferenceError
when accessed in the main function body).
If there is no function encapsulating our variable declared with var
(or no block surrounding a variable declared with let
or const
), it is said to be in the Global scope. The global scope is visible to every function and block in our program. [9]
let globalOne = "Hello";
var globalTwo = "Hi";
if (true === true){
if (2 === 2){
var globalThree = "Hey";
}
}
function imALittleFunction(){
console.log(globalOne); // Hello
console.log(globalTwo); // Hi
console.log(globalThree); // Hey
}
imALittleFunction();
There is no block encapulating globalOne
or globalTwo
, therefore they are tied to the global scope. This means they can be accessed within imALittleFunction()
. Despite being declared in a block, globalThree
is also a global variable as it is declared using var
. There is no surrounding function for globalThree
to tie itself to, and so it becomes part of the global scope.
One thing to note, is that block-scope variables can effectively be tied to a function in a similar way to function-scoped variables: we just have to declare them at the top of our function.
function aFunction(){
let x = 5;
if (true === true){
var y = 3;
console.log(x); // 5
}
console.log(y); // 3
console.log(x); // 5
}
Here, x
is tied to the block that defines our function, and so is available to the whole function (effectively function scoped). y
is also available to the whole function despite being declared in a nested block, as variables declared with var
are function scoped and will continue to 'bubble up' through blocks until they can tie themselves to some function, or the global scope.
Function-scoped variables are tied to the function they are declared within, rather than just the block they are declared within. If there is no surrounding function (i.e. the variable is declared only within blocks), the variable becomes a global variable.
While this may seem like an academic difference between var
and let
/const
, usually let
and const
are preferred. This is a somewhat debateable assertion, however many mainstream figures in the JavaScript ecosystem advocate for dropping var
. [10] This is because many other established languages are block scoped (particularly C-derived languages like Java and C++ [11] [12]) and consistency across languages is helpful for better communication between developers. Furthermore, other prominent figures, such as Kyle Simpson, advocate for following the Principle of Least Exposure (POLE) when declaring our variables. [13] This means we want a variable to be exposed to the minimum number of scopes possible. There are multiple reasons for doing this, however one is because it helps to avoid name collisions. This is where we attempt to use the same variable name twice in the same scope (which would lead to it being redefined). If a variable gets declared in the global
scope, then no function would be able to use that variable name. Therefore, we usually try to avoid putting variables in the global
scope as much as possible, as this heavily violates the POLE.
In general, keep globally accessible variables to a minimum; only give a variable the smallest scope it needs to do its job.
Another big reason we like to stick to the POLE principle, is because it minimises the number of things that can go wrong. If one function has a bug in it, it is easier to fix if the bug remains isolated to that function; the more contact it has with the world outside of it, the more potential there is for that one bug to affect other areas of the code base.
As var
is much more likely to overexpose a variable compared to let
or const
(due to its nature of attaching to functions rather than blocks, and so its proclivity to 'bubble up' through blocks until reaching a function), we usually always use let
or const
these days. Older code may still use var
, however, as let
/const
were released in the ES6 edition of JavaScript (2015).
Use
let
orconst
to declare variables, unless you have a good reason not to. This is becausevar
tends to overexpose variables in ways that are harder to notice compared to block scoped variables.
Note - this section digs a little into the technicalities of JS under-the-hood: TL;DR is try to declare all your variables at the top of the scope in which they are used, and don't try to access them before they are defined.
Now there's a cute little edge case here.
// global scope
if (/* condition */){
// block 1
if (/* second condition */){
// block 2
console.log(whatAmI) // ???
};
// back in block 1
let whatAmI = "Hello";
};
// global scope
I said that child blocks inherit their parent's scope. Despite being declared after block 2, lexically speaking (as written in code), whatAmI
is still declared in block 1, which is block 2's parent scope. Therefore, technically we could argue that we should still have access to whatAmI
in block 2.
This is a more specific example of a more general concept in JS.
//
//
//
console.log(whatAmI); // ???
let whatAmI = "Hello";
Here's the same issue, just without many different scopes to worry about. Depending on your JS settings and how you declared whatAmI
, you might see either see undefined
from the console.log
, or an error relating to "accessing whatAmI
before initialization". What you won't see, however, is a ReferenceError: whatAmI is not defined
, which is the error that normally occurs when we try and call a variable that doesn't exist within the scope it is accessed from. It seems that, even though we declare the variable whatAmI
underneath this console.log(whatAmI)
, the console.log
is still aware that whatAmI
exists within the current scope (else, we would see the aforementioned ReferenceError
).
This is because of a concept in JS called hoisting. Before execution, JS does a quick scan through your code in a stage called lexing/tokenizing. [14] The details are unimportant (for the current discussion, mind you, we'll definitely look into this another day!), but it just converts your code into a format that the JS engine can understand better before it actually executes the code. [15] During this stage, when the JS engine enters a new scope (remember, blocks define new scopes and so this occurs when we enter a new block of code), it will move all your variable declarations to the top of that scope. However, it will only move their declarations, not the values assigned to them. [16]
//
//
//
console.log(whatAmI); // ???
let whatAmI = "Hello";
This can be thought of as the above being converted into the below.
let whatAmI;
//
//
//
console.log(whatAmI); // ???
whatAmI = "Hello";
The let whatAmI;
line is perfectly valid syntax in JS - we don't have to include as assignment with a declaration (except when using const
). This means that, by the time the console.log(whatAmI)
occurs, JS is aware that there will be a variable called whatAmI
, but for now it has no assigned value.
HOWEVER, it is very important to note, and very confusing, that JS is not actually saying at the top of the file let whatAmI = undefined
. Else, we wouldn't see that error about using a variable before its initialization. Nor is it actually saying let whatAmI;
, which would lead to the same thing. All hoisting does is tell the JS engine not to freak out when it sees a variable name that will be defined in the future, and allows JS to catch "compile time" errors.
BUT, we are still left in a situation where the following code may still leave us with an error about calling a variable before its initialization. It's important to note here, however, that in this case whatAmI
is "not defined" during the log, not undefined
(remember I said before about how there's some weird design quirks built into JS from years long past, and sometimes they're a little wacky? Yeah this is one of them).
//
//
//
console.log(whatAmI); // ReferenceError: cannot access 'a' before initialization
let whatAmI = "Hello";
To throw another wrench into the works, what if we instead declare whatAmI
using var
instead of let
?
//
//
//
console.log(whatAmI); // undefined
var whatAmI = "Hello";
This time, we get no error, and whatAmI
actually is undefined
! In this case, JS is literally moving the declaration to the top. The following is actually what is happening during hoisting.
var whatAmI;
//
//
//
console.log(whatAmI); // undefined
whatAmI = "Hello";
Because with let
and const
we are not actually moving their declaration to the top, there exists an (albeit extremely short) period of time between entering the scope and when the variable is actually declared. This does not happen with var
as the declaration is literally moved to the top of the scope in which it is declared. let
and const
are still hoisted, however they are also in a state of limbo during the time between entering the scope and when they are actually declared. This period of time is known as the temporal dead zone, which, in my opinion, sounds waaaay to cool for what it is. It's literally the (very) short space of time between entering a scope, and the declaration of a variable with let
/const
. [17]
// entered scope
// this
// is
// all
// the
// TDZ
// for
// "a"
let a = 5;
That was perhaps a little deeper diving than we needed to go, however that's a taste of some of the inner workings of JS.
While we can talk about whether variables are block scoped or function scoped, we didn't really answer the question of "what defines a scope?". Well, we just said "its between a set of curly braces", however that's not entirely true. In a broader sense, we can talk about a scope being formed dynamically or lexically. [18] Dynamic scoping means that our variables become bound to a function execution - any variables declared between when the function is called and when it returns are within the same scope. To avoid confusion: JavaScript does not use dynamic scoping. However, below is an example which could help the conceptualize it.
function callMe() {
let a = 5;
};
function dynamicScope(){
callMe();
console.log(a); // 5
return;
};
dynamicScope();
In a dynamic scoping approach, we call dynamicScope()
and start a new scope. We then call callMe()
which declares a variable called a
. Now, dynamicScope()
hasn't returned yet so we are still within the scope of dynamicScope()
. This means, in a dynamic scoping paradigm, a
is actually in scope meaning we can console.log(a)
and get its value, 5
.
However, this is not how JS behaves.
We have spent all our time thus far talking about this unidirectional flow of variables, and how variables are scoped between blocks of code. This is what lexical scoping is: scopes depend only on how the code is written, not how it runs. Whether we run another function or not, if we do not declare a variable within our set of curly braces, it does not exist in that scope. Lexical means "of words" or "as written", and so our mental model we were building up of having separate blocks to divide up our scopes visually is correct.
// global
if (true){
// global AND block 1
if (true){
// global AND block 1 AND block 2
// etc...
};
// block 1
};
// global
Just by looking at the code structure, regardless of variables or functions called, we can divide it up into blocks and begin to understand the different scopes within a file. NOTE - dynamic/lexical scoping is not mutually exclusive with function/block scoping. The former indicates when scope is determined (during run time or as written), the latter indicates what the boundaries of this scope are (any blocks of code, or only functions). When using let
or const
exclusively, we could say JS is lexically block scoped. If we use var
exclusively, it would be lexically function scoped.
JavaScript is lexically scoped, meaning our scopes are determined by sets of curly braces ( { } ) surrounding some code. This does not depend on what functions are called, or the order they are called in - we can always visually see our various scopes by looking at the nested blocks.
So, now you're an expert in scopes and the variables that get tied to them. Now that you are an expert, it means we can talk about some really cool stuff JavaScript has to offer. One of these is the concept of closures.
A closure is when a function is returned from another function, and remembers its scope. [19] That's a little wordy, so let's look at an example. The first part is simple - we must return a function from a function.
function outer(){
function inner (){
// something
};
// something
return inner;
};
When we assign outer()
to a variable, we are effectively assigning this variable to inner
(note - not the return value from calling inner()
, we literally assign our variable to the whole function definition).
function outer(){
function inner (){
// something
};
// something
return inner;
};
const myFunc = outer();
It's easy to get confused about assigning a function to a variable vs a function's return
value, and so for clarity the above can be thought of as equivalent to:
function outer(){
function inner (){
// something
};
// something
return inner;
};
const myFunc = function inner(){ // assigning myFunc to outer() is like saying this
// something
};
Now, obviously we are redeclaring function inner
here, and so this is not valid JS code, but it gives us a useful mental model to understand what is happening here.
Following on from this, if we were to perform some operation within inner
such as a console.log
, after we assign myFunc
to the return value of outer()
, calling myFunc()
will execute the code within inner
.
function outer(){
function inner (){
console.log("I am logged when inner is called");
};
// something
return inner;
};
const myFunc = outer();
// myFunc is now equivalent to inner
myFunc(); // "I am logged when inner is called"
Now, this is the first part to a closure. However, the second part to a closure is what gives it the name of a closure.
The second part to a closure is that the returned function remembers its scope. What does this mean, in practice? It means that the returned function (inner
in the above example) must make some reference to a variable in it's scope (remember: in JavaScript we define our scopes lexically, meaning "as written").
function outer(){
let x = 5;
function inner (){
// uses variable x which is declared in the parent block of inner
console.log(x);
};
return inner;
};
const myFunc = outer();
myFunc(); // 5
From our understanding of JavaScript's lexically scoped nature, we know that x
is within the scope of the inner()
function. Therefore, we have access to x
within this function. The thing that makes this interesting, is that we can call myFunc()
to execute the code seen in the function inner
definition which references a variable declared in function outer
. Usually, once a function has finished executing, the variables declared within it are thrown away (literally, they are garbage collected by the JavaScript engine). Well, this doesn't seem to be happening here, as we still have access to x
via calling myFunc()
. By making refernce to x
within the function definition of inner
and returning this function definition from another function, we are preventing x
from being thrown away! We could say that inner
is closing over x
(hence the name, "closure").
This gets even more interesting, as the variable we "close" over is not static: we can mutate x
and these changes would be tracked.
function outer(){
let x = 5;
function printAndIncrementX (){
console.log(x);
x++; // increment X every time every time this function is called
};
return printAndIncrementX;
};
const myFunc = outer();
myFunc(); // 5
myFunc(); // 6
myFunc(); // 7
Here, we access x
in printAndIncrementX
and assign our printAndIncrementX
function definition to myFunc
. Then, every time we call myFunc()
we will execute the code in printAndIncrementX
. On the first call, we log x
(which gives 5
), and then increment x
. The next time we call it, we log the current value of x
(now 6
) and increment x
again, and so on.
This process of a returned function closing over some variable in its lexical scope is called a closure.
Note: again, somewhat confusingly, the lexical environment we have access to through this returned function can also be referred to as a "closure". However, we will cover this when we delve deeper into the mechanics behind closures another day.
We could throw out very esoteric examples of closures, and that's all well and good. But how are they actually used in every day code? One good example are React Hooks. [20] The useState()
hook makes use of closures in order to function correctly. Given our new understanding of closures, we could actually recreate a primitive version of the useState()
hook!
We know that useState
returns a stateful variable with some initial value, and a function to update this value (we shall ignore the re-rendering aspect entirely here!).
function primitiveUseState(initialValue){
let useStateVariable = initialValue;
function pieceOfState(){
// stuff
return useStateVariable
};
function setPieceOfState(newValue){
//stuff
useStateVariable = newValue;
};
return [pieceOfState, setPieceOfState];
};
const [someState, setSomeState] = primitiveUseState("");
Let's walk through this. First we make a call to primitiveUseState()
passing in our initialValue
of an empty string. Next, we assign a variable called useStateVariable
to our initialValue
- this variable is used to keep track of the current value of our stateful variable internally (internally, here, referring to within primitiveUseState()
). Next we see our pieceOfState()
function, which returns this internal useStateVariable
which we are using to keep track of the current value of our state. Notice, pieceOfState()
is closing over this useStateVariable
, therefore we are able to manipulate it long after primitiveUseState()
has finished executing. We also define another function, which merely takes in some newValue
and assigns this to the internal useStateVariable
. Finally, we return an array containing our two functions. Note - the // stuff
refers to other things React is doing in their actual useState
such as error checking and re-rendering our component.
Now we have someState
assigned to our pieceOfState
function, and setSomeState
assigned to our setPieceOfState
function.
const [someState, setSomeState] = primitiveUseState("initial");
Remember, both someState
and setSomeState
are function definitions in this primitive example of useState
, and so we access the current value of someState
by calling someState()
. This is different to React, where someState
would literally be a variable we can use rather than a function we call to get our state, however the underlying mechanics are the same in both instances.
// Our primitive useState example
const [count, setCount] = primitiveUseState(0);
console.log(count()); // 0
setCount(2);
console.log(count()); // 2
// React useState
const [count, setCount] = useState(0);
console.log(count); // 0
setCount(1);
console.log(count); // 1
Here we covered a brief introduction to scope and closures in JavaScript. We defined scope as "where variables can be accessed from", and covered the difference between block scoping (variables attach to code blocks; { }
) and function scoping (variables attach to the nearest encapsulating parent function). We also briefly covered hoisting and the strange-yet-cool-sounding temporal dead zone which occurs when we attempt to use variables declared with let
or const
before their declaration within the same scope, and how the easiest way to avoid this is simply to declare our variables at the top of the function/block which needs them. We explored how JavaScript uses lexical scoping, which is when we can define the different scopes purely by how the code is written. We also mentioned that child blocks will inherit the scope of their parent blocks (or functions, if we use var
), and so on until we reach the global scope. We also briefly looked at closures in JavaScript: where we return some inner function from some outer function, where the inner function makes reference to some variable declared in the outer function. Finally, we applied these principles to make a crude implementation of React's useState
hook.