DEV Community

Cover image for JavaScript Scoping Essentials You Should Know: Hoisting, Closures, and Lexical Scope
Olamide Ilori
Olamide Ilori

Posted on

JavaScript Scoping Essentials You Should Know: Hoisting, Closures, and Lexical Scope

Introduction: When JavaScript Gets Tricky

Sometimes you stumble upon a tiny snippet of JavaScript and immediately feel like the language is testing your sanity: Take the example below:

Example 1:

for (var i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
Enter fullscreen mode Exit fullscreen mode

What do you think it prints? 1 2 3, 3 3 3, or 4 4 4?

If you guessed 1 2 3 or 3 3 3, don’t feel too bad, you’ve just fallen into JavaScript’s classic “gotcha” trap. The actual output is: 4 4 4. You may ask, what is 4 business in all of this, how on earth is 4 <= 3, well, welcome to JavaScript my friend.

Here’s another one:

Example 2:

for (let i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
Enter fullscreen mode Exit fullscreen mode

What do you think it prints? 1 2 3, 3 3 3, or 4 4 4?

Surprise! It prints: 1 2 3

The difference? It’s all about how you declare your variables (var or let). var behaves like a single shared memory cell, while let creates a fresh copy for each iteration. In other words, JavaScript remembers differently depending on your choice. Sounds too technical already? Don't worry, I would break it all down for you at the end of this article.

But first, let's talk about

1. Scoping in JavaScript: Where Your Variables Live

The real culprit behind these surprises is scoping. Scoping determines where variables are accessible in your code. JavaScript utilizes lexical (or static) scoping, as opposed to dynamic scoping. What then is lexical scoping?

Lexical Scoping simply means that a variable's scope is determined by where it is written in the source code, not by where it is called at runtime.
In other words:

  • When a function is created, it remembers the scope in which it was defined.

  • It does not matter where the function is called; only where it was defined matters.
    Let's take an example.

Example 3:

let x = 1;
function foo() {
   console.log(x);
}

function bar() {
   let x = 2;
   foo();
}
bar(); 
Enter fullscreen mode Exit fullscreen mode

Outputs
(Lexical Scoping): 1
(Dynamic Scoping) 2

Let's break down the lexical scoping concept:
foo() is called inside the function bar(), the value of x that was captured what determined by where foo() was written. As at creation of function of the function foo(), x is 1. Even though foo() is called inside bar(), a value x = 2 exists there, it doesn’t matter. As we said earlier: “It does not matter where the function is called, only where it was defined.”

Even though foo() is called inside bar(), a value x = 2 exists there, it doesn’t matter. As we said earlier: “It does not matter where the function is called, only where it was defined.”

A dynamic scoping just does the opposite of lexical scoping, that can kind of scoping technique is found in "ancient" programming languages like Lisp.

Now that we understand the scoping technique javascript uses, let's talk about the scope of javascript variable var, let and const.

var - is function-scoped (or global if declared outside a function) variable and can be accessed anywhere inside the function it was declared.

let / const - are block-scoped, only exists inside { } where declared.

Let's visualise this with an example:

Example 4:

function testVar() {
   if (true) {
      var x = 10;
      let y = 15
   }
    console.log(x); // 10 → accessible outside the block to the whole function (function-scoped) 
    console.log(y); // throws a ReferenceError: y is not defined
}
Enter fullscreen mode Exit fullscreen mode

Notice how x logs a 10 in the console? This is because even though it is defined in a block (if statement), it's scope is the function and it's accessible everywhere in the function testVar()

For let on the other hand, it throws a ReferenceError because it's scope is confined to the block (if statement) therefore, console.log can't seem to find (reference) it. const is similar to let, just that is only holds a constant value and cannot be reassigned a new value unlike let.

Remember how we are able to access var in the function scope even though it was declared inside a block? That takes us to the next concept I would be talking about, Hoisting.

2. Hoisting: JavaScript’s Magic Trick
Do you know that before your code runs, JavaScript secretly moves declarations to the top of their scope. This is called hoisting. All variable types and functions are hoisted in JavaScript, how they are done is what differs.

var → hoisted and initialised to undefined.
let / const → hoisted but cannot be accessed yet (Temporal Dead Zone)
Function declarations → fully hoisted (body included)
Function expressions → only the variable is hoisted

Let's break down the above with examples.

Example 5: var hoisting

console.log(a); // undefined
var a = 5;
console.log(a); // 5
Enter fullscreen mode Exit fullscreen mode

What happens internally:

var a; // declaration is hoisted (moved to the top of it's scope)
console.log(a); // undefined (no value yet)
a = 5;  // initialization happens here
console.log(a); // 5
Enter fullscreen mode Exit fullscreen mode

var declarations are hoisted and initialized with undefined.

Example 6: Hoisting with let and const

console.log(b); // ReferenceError
let b = 10;

console.log(c); // ReferenceError
const c = 20;
Enter fullscreen mode Exit fullscreen mode

let and const are hoisted too, but they are in a temporal dead zone (TDZ) until the line where they are declared. Accessing them before the declaration throws ReferenceError.

Example 6: Hoisting with functions

greet(); // "Hello"
function greet() {
    console.log("Hello");
}
Enter fullscreen mode Exit fullscreen mode

The entire function (declaration + body) is hoisted. You can call it before its declaration.

Example 7: Hoisting with function expressions:

hello // undefined
hello(); // TypeError: hello is not a function
var hello = function() { console.log("Hi"); };
Enter fullscreen mode Exit fullscreen mode

Only the variable hello is hoisted (as undefined) not the function body because it is defined as a var (remember what we said about var hoisting in e.g 5). So calling it early throws an error.

Example 8:

hello // Reference Error 
hello(); // Reference Error
const hello = function() { console.log("Hi"); };
Enter fullscreen mode Exit fullscreen mode

This just extends what happens with let / const because it's a function expression. What happens with function expression depends on the type of variable.

Hoisting explains why some code “seems to exist before it’s declared.” Think of it as JavaScript sneakily rearranging your furniture before you enter the room. Finally, let's talk about closures.

3. Closures: Functions That Never Forget

A closure is a function that remembers variables from the scope where it was created, even after that scope is gone. Closures are created by functions, exist inside functions, and are about how functions capture variables.

Closures are why your setTimeout callbacks could remember i differently depending on var vs let.

Example 10: Counter Closure

function createCounter() {
  let count = 0;

  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 1
counter(); // 2
counter(); // 3
Enter fullscreen mode Exit fullscreen mode

Closure is what allows the function counter() to still be able to access count declared in createCounter even after it has been called.

Let's take a look at closure in loops and go back to genesis of everything, e.g 1 & 2.

// Example 1
for (var i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000); // 4 4 4
}

// Loop start
// i = 1 → schedule callback #1
// i = 2 → schedule callback #2
// i = 3 → schedule callback #3
// Loop ends, i becomes 4

// Essentials, var uses the same variable (i) all through. 
// Returns the same i that is already 4


for (let i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000); // 1 2 3
}

/ Loop start
// Closure #1 → creates a new variable i1 → 1
// Closure #2 → creates a new variable i2 → 2
// Closure #3 → creates a new variable i3 → 3

// let creates stores a new variable (i1, i2, i3) in memory for each loop
//therefore each iteration has it's own i
Enter fullscreen mode Exit fullscreen mode

4. Connecting the Dots

Lexical scoping → decides which variables a function can see
Hoisting → moves declarations to the top, creating surprises if you’re not careful
Closures → allow functions to “remember” variables, leading to powerful patterns or head-scratching bugs

Master these three concepts, and suddenly JavaScript’s “tricky snippets” start to feel predictable.

5. Conclusion
JavaScript loves to surprise us, but understanding scoping, hoisting, and closures gives you superpowers:

You’ll predict outputs accurately
You’ll write cleaner, safer code
You’ll stop wondering if JS is secretly messing with you

And remember: JavaScript isn’t trying to confuse you… it’s just teaching you to respect its rules. 😉

Top comments (0)