Skip to main content

πŸ” Lesson 8: Scope & Hoisting

Understand where your variables live, which code can see them, and how JavaScript moves declarations behind the scenes.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the difference between global, function, and block scope
  • Predict where a variable is accessible and where it isn't
  • Understand how the scope chain resolves variable lookups
  • Describe how hoisting works differently for var, let, const, and functions
  • Recognize and explain the Temporal Dead Zone
  • Understand what closures are and see a basic example

Estimated Time: 45 minutes

Project: Debug scope-related bugs and build a simple counter with closures

πŸ“‘ In This Lesson

Introduction

Scope answers one simple question: "Can I use this variable here?" Every variable in JavaScript lives in a specific scope, and that scope determines which parts of your code can read or modify it.

Understanding scope is what separates beginners from developers who can confidently debug tricky bugs. Most "why is this undefined?" mysteries come down to scope.

graph TD A["Global Scope"] --> B["Function Scope"] A --> C["Function Scope"] B --> D["Block Scope"] B --> E["Block Scope"] C --> F["Block Scope"] style A fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style B fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style C fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style D fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style E fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style F fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b

Global Scope

Variables declared outside of any function or block are in the global scope. They're accessible from anywhere in your code.


// Global scope
const appName = "My Cool App";
let userCount = 0;

function showApp() {
    // βœ… Can access global variables inside functions
    console.log(appName);  // "My Cool App"
    userCount++;
}

showApp();
console.log(userCount);  // 1 β€” global was modified
                

⚠️ Avoid Global Variables

Global variables are accessible everywhere, which sounds convenient β€” but it means any code can change them. In larger programs, this leads to hard-to-track bugs. Limit globals to things that truly need to be app-wide (like configuration constants). Keep everything else inside functions or blocks.


// ❌ Too many globals β€” anyone can break these
let score = 0;
let lives = 3;
let level = 1;
let playerName = "Hero";

// βœ… Better β€” encapsulate in a function or object
function createGameState() {
    return {
        score: 0,
        lives: 3,
        level: 1,
        playerName: "Hero"
    };
}

const game = createGameState();
                

Function Scope

Variables declared inside a function are only accessible within that function. This applies to var, let, and const.


function calculateTotal() {
    const taxRate = 0.08;    // Only exists inside this function
    let subtotal = 50;
    let total = subtotal + (subtotal * taxRate);
    console.log(total);      // 54
}

calculateTotal();
// console.log(taxRate);  // ❌ ReferenceError: taxRate is not defined
// console.log(subtotal); // ❌ ReferenceError: subtotal is not defined
                

This is a feature, not a limitation. Function scope keeps variables private β€” they can't accidentally interfere with other parts of your code.


// Each function has its own scope
function greetEnglish() {
    const greeting = "Hello";   // Only in greetEnglish
    console.log(greeting);
}

function greetSpanish() {
    const greeting = "Hola";    // Different variable, different scope
    console.log(greeting);
}

greetEnglish();  // "Hello"
greetSpanish();  // "Hola"
// No conflict β€” each 'greeting' is independent
                

Parameters Are Function-Scoped Too


function double(number) {
    return number * 2;
}

double(5);  // 10
// console.log(number);  // ❌ ReferenceError β€” 'number' only exists inside double()
                

Block Scope

A block is any code between curly braces { } β€” if/else blocks, loops, or even standalone braces. Variables declared with let and const are block-scoped.


if (true) {
    let blockVar = "I'm inside the block";
    const alsoBlock = "Me too";
    console.log(blockVar);   // βœ… "I'm inside the block"
}

// console.log(blockVar);   // ❌ ReferenceError
// console.log(alsoBlock);  // ❌ ReferenceError
                

var Is NOT Block-Scoped

This is one of the most important differences between var and let/const. The var keyword ignores block boundaries β€” it's only scoped to functions.


if (true) {
    var leaked = "I escaped the block!";
    let contained = "I stayed inside.";
}

console.log(leaked);      // βœ… "I escaped the block!" β€” var leaks out!
// console.log(contained); // ❌ ReferenceError β€” let stays in the block
                

Loops and Block Scope


// let creates a new binding for each iteration
for (let i = 0; i < 3; i++) {
    // Each iteration has its own 'i'
}
// console.log(i);  // ❌ ReferenceError β€” i doesn't exist here

// var leaks the loop variable
for (var j = 0; j < 3; j++) {
    // All iterations share the same 'j'
}
console.log(j);  // 3 β€” var leaked out of the loop!
                

❌ The Classic var Loop Bug

This is one of the most famous JavaScript gotchas. With var, delayed callbacks all share the same variable.


// ❌ Bug with var
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Prints: 3, 3, 3  (not 0, 1, 2!)
// All callbacks see the SAME 'i', which is 3 after the loop

// βœ… Fixed with let
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100);
}
// Prints: 0, 1, 2  (each callback has its own 'i')
                    
Feature var let const
ScopeFunctionBlockBlock
Hoisted?Yes (initialized to undefined)Yes (but in TDZ)Yes (but in TDZ)
Re-declarable?YesNoNo
Reassignable?YesYesNo

The Scope Chain

When JavaScript encounters a variable, it looks for it in the current scope first. If it doesn't find it, it looks in the outer scope, then the next outer scope, all the way up to the global scope. This is the scope chain.


const globalVar = "I'm global";

function outer() {
    const outerVar = "I'm in outer";

    function inner() {
        const innerVar = "I'm in inner";

        // inner can see everything above it
        console.log(innerVar);   // βœ… "I'm in inner"
        console.log(outerVar);   // βœ… "I'm in outer"
        console.log(globalVar);  // βœ… "I'm global"
    }

    inner();

    // outer can see its own scope and global, but NOT inner
    console.log(outerVar);   // βœ… "I'm in outer"
    console.log(globalVar);  // βœ… "I'm global"
    // console.log(innerVar); // ❌ ReferenceError
}

outer();
                
graph TD A["Global Scope
globalVar = 'I'm global'"] --> B["outer() Scope
outerVar = 'I'm in outer'"] B --> C["inner() Scope
innerVar = 'I'm in inner'"] C -->|"Looks up for outerVar"| B C -->|"Looks up for globalVar"| A style A fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style B fill:#eff6ff,stroke:#3b82f6,stroke-width:2px,color:#1e293b style C fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b

Variable Shadowing

When an inner scope declares a variable with the same name as an outer scope variable, the inner one shadows (hides) the outer one within that scope.


const color = "blue";  // Global

function paint() {
    const color = "red";  // Shadows the global 'color'
    console.log(color);   // "red" β€” uses the local one
}

paint();
console.log(color);  // "blue" β€” global is unchanged
                

πŸ’‘ Shadowing Is Usually Accidental

Reusing variable names across scopes makes code confusing. If you find yourself shadowing a variable, consider using a more descriptive name instead. Most linters will warn you about this.

Hoisting in Detail

Hoisting is JavaScript's behavior of moving declarations to the top of their scope before code execution. But different declarations are hoisted differently.

var Hoisting

var declarations are hoisted and initialized to undefined. This means you can reference a var before its declaration β€” you'll get undefined instead of an error.


console.log(name);  // undefined (not an error!)
var name = "Ray";
console.log(name);  // "Ray"

// JavaScript sees it as:
// var name;           // Declaration hoisted, initialized to undefined
// console.log(name);  // undefined
// name = "Ray";       // Assignment stays in place
// console.log(name);  // "Ray"
                

let and const Hoisting (Temporal Dead Zone)

let and const are hoisted too, but they're not initialized. Accessing them before their declaration causes a ReferenceError. The zone between the start of the scope and the declaration is called the Temporal Dead Zone (TDZ).


// console.log(age);  // ❌ ReferenceError: Cannot access 'age' before initialization
let age = 25;
console.log(age);     // 25

// The TDZ exists from scope start to declaration
{
    // --- TDZ for 'myVar' starts here ---
    // console.log(myVar);  // ❌ ReferenceError (TDZ!)
    // --- TDZ for 'myVar' ends here ---
    let myVar = "safe";
    console.log(myVar);     // βœ… "safe"
}
                

Function Hoisting


// Function DECLARATIONS are fully hoisted (name + body)
sayHello();  // βœ… "Hello!" β€” works before definition

function sayHello() {
    console.log("Hello!");
}

// Function EXPRESSIONS are NOT fully hoisted
// greet();  // ❌ TypeError: greet is not a function

const greet = function() {
    console.log("Hi!");
};

greet();  // βœ… "Hi!" β€” works after definition
                
graph TD A["Hoisting Behavior"] --> B["var"] A --> C["let / const"] A --> D["function declaration"] A --> E["function expression"] B --> F["Hoisted + initialized to undefined"] C --> G["Hoisted but NOT initialized
Temporal Dead Zone"] D --> H["Fully hoisted
name + body"] E --> I["Variable hoisted per its keyword
function body NOT hoisted"] style F fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style G fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#1e293b style H fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b style I fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b

βœ… Pro Tip: Don't Rely on Hoisting

Even though function declarations are hoisted, writing code that depends on hoisting makes it harder to follow. Declare your variables at the top of their scope and define functions before you call them. Let hoisting be something you understand, not something you depend on.

Introduction to Closures

A closure is when a function "remembers" the variables from its outer scope, even after the outer function has finished executing. This is one of JavaScript's most powerful features.


function createGreeter(greeting) {
    // The inner function "closes over" the greeting variable
    return function(name) {
        console.log(`${greeting}, ${name}!`);
    };
}

const sayHello = createGreeter("Hello");
const sayHola = createGreeter("Hola");

sayHello("Ray");    // "Hello, Ray!"
sayHola("MarΓ­a");   // "Hola, MarΓ­a!"

// createGreeter has finished running, but the returned
// functions still remember their 'greeting' values!
                

Practical Example: Counter


function createCounter() {
    let count = 0;  // Private variable β€” only the returned functions can access it

    return {
        increment() { count++; },
        decrement() { count--; },
        getCount() { return count; }
    };
}

const counter = createCounter();
counter.increment();
counter.increment();
counter.increment();
counter.decrement();
console.log(counter.getCount());  // 2

// count is truly private β€” no one can access it directly
// console.log(counter.count);  // undefined β€” not a property
// console.log(count);          // ❌ ReferenceError
                
graph TD A["createCounter() runs"] --> B["count = 0 created"] B --> C["Returns object with
increment, decrement, getCount"] C --> D["createCounter finishes"] D --> E["But count SURVIVES
in closure memory!"] E --> F["counter.increment()
count becomes 1"] F --> G["counter.getCount()
returns 1"] style E fill:#fef3c7,stroke:#f59e0b,stroke-width:2px,color:#1e293b style G fill:#ecfdf5,stroke:#22c55e,stroke-width:2px,color:#1e293b

πŸ’‘ Why Closures Matter

Closures are the foundation of many JavaScript patterns: data privacy, event handlers, callbacks, and module patterns. You don't need to master them right now β€” just understand the concept. You'll see them everywhere as you progress, and they'll make more sense each time.

Hands-on Exercise

πŸ‹οΈ Exercise: Scope Detective & Closure Counter

Objective: Debug scope issues and build a closure-based counter.

Part 1: Scope Detective

Predict the output of each console.log before running the code. Then run it and check.


// What does each console.log output? Predict first!

let a = "global a";
var b = "global b";

function test() {
    let a = "function a";
    var c = "function c";

    if (true) {
        let a = "block a";
        var d = "function d";  // var is function-scoped!
        console.log("1:", a);  // ???
        console.log("2:", b);  // ???
    }

    console.log("3:", a);  // ???
    console.log("4:", c);  // ???
    console.log("5:", d);  // ???
}

test();
console.log("6:", a);  // ???
console.log("7:", b);  // ???
// console.log("8:", c);  // What happens here?
                    

Part 2: Build a Score Tracker


// TODO: Create a closure-based score tracker
// It should return an object with these methods:
//   addPoints(points)  β€” adds points to the score
//   penalty(points)    β€” subtracts points (min score is 0)
//   getScore()         β€” returns the current score
//   reset()            β€” resets the score to 0

// Usage:
// const tracker = createScoreTracker();
// tracker.addPoints(10);
// tracker.addPoints(25);
// tracker.penalty(5);
// console.log(tracker.getScore());  // 30
// tracker.reset();
// console.log(tracker.getScore());  // 0
                    
πŸ’‘ Hint

Part 1: Remember β€” let is block-scoped, var is function-scoped. The var d inside the if block belongs to the function scope, not the block. Variable shadowing means inner a hides outer a within its scope.

Part 2: Follow the createCounter pattern from the lesson. Declare a let score = 0 inside the function and return an object whose methods read and modify that variable. For penalty, use Math.max(0, score - points) to prevent going below zero.

βœ… Solution

// Part 1 Answers:
// 1: "block a"     (let a shadows function a inside the block)
// 2: "global b"    (b found via scope chain in global)
// 3: "function a"  (block scope ended β€” back to function a)
// 4: "function c"  (var c in function scope β€” accessible)
// 5: "function d"  (var d is function-scoped, not block-scoped!)
// 6: "global a"    (global a was never modified)
// 7: "global b"    (global b accessible)
// 8: ReferenceError (c is function-scoped β€” not accessible globally)


// Part 2: Score Tracker
function createScoreTracker() {
    let score = 0;

    return {
        addPoints(points) {
            score += points;
        },
        penalty(points) {
            score = Math.max(0, score - points);
        },
        getScore() {
            return score;
        },
        reset() {
            score = 0;
        }
    };
}

const tracker = createScoreTracker();
tracker.addPoints(10);
tracker.addPoints(25);
tracker.penalty(5);
console.log(tracker.getScore());  // 30

tracker.penalty(100);
console.log(tracker.getScore());  // 0 (can't go below zero)

tracker.addPoints(50);
tracker.reset();
console.log(tracker.getScore());  // 0

// Score is truly private
// console.log(tracker.score);  // undefined
                        

🎯 Quick Quiz

Question 1: What is the output?

console.log(x);
var x = 10;

Question 2: Which keyword creates a block-scoped variable?

Question 3: What is a closure?

Summary

πŸŽ‰ Key Takeaways

  • Global scope: variables declared outside functions/blocks β€” accessible everywhere (use sparingly!)
  • Function scope: variables inside a function β€” private to that function
  • Block scope: let/const inside { } β€” private to that block. var ignores blocks!
  • Scope chain: JavaScript looks outward through nested scopes to find variables
  • Hoisting: declarations move to the top of their scope. var gets undefined; let/const enter the TDZ
  • Closures: inner functions remember their outer scope's variables, enabling data privacy and powerful patterns
  • Always use let/const β€” avoid var to prevent scope leaks

πŸ“š Additional Resources

πŸš€ What's Next?

You've completed Module 2! You now understand control flow, functions, and scope. In Module 3, you'll start working with data β€” beginning with arrays, the most important data structure in everyday JavaScript.

πŸŽ‰ Module 2 Complete!

You've mastered control flow and functions β€” conditionals, loops, functions, and scope. Your programs can now make decisions, repeat actions, organize code, and manage data access. On to data structures!