🔭 Function Scope and Execution Context
Every variable in JavaScript lives somewhere, and every function runs inside an environment that decides what it can see. Master scope, the scope chain, the call stack, this, and closures, and JavaScript's most confusing bugs turn into predictable rules.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Describe global, function, and block scope and how the scope chain resolves a variable
- Explain hoisting and the temporal dead zone for
let/const - Trace an execution context through the call stack
- Predict the value of
thisunder the five binding rules - Define a closure and use it for private state and function factories
Estimated Time: 40–50 minutes • Difficulty: Intermediate
Hands-on: Build a private counter with a closure and explain why it works.
In This Lesson
Scope & Context Overview
Two related ideas govern how JavaScript runs your code:
- Scope answers "which variables can this code see?"
- Execution context answers "what is the environment this code runs in — its variables, its scope chain, and its
this?"
💡 The office-building analogy: Scopes are floors and rooms, each with its own equipment (variables). Execution contexts are the active work sessions happening in those rooms. The call stack is the elevator log of which floors you've stepped into. And a closure is a key you carry out of a room that still opens it later.
Types of Scope
Global scope
Variables declared outside any function or block are global and visible everywhere. Keep these to a minimum — global state is where bugs breed.
const appName = 'MyApp'; // global
function showInfo() {
console.log(appName); // reachable from inside
}
showInfo(); // "MyApp"
Function scope
Anything declared inside a function is invisible outside it. Variables declared with var are scoped to the whole function.
function calculateTotal(items) {
let total = 0;
for (let i = 0; i < items.length; i++) {
total += items[i].price;
}
return total;
}
// console.log(total); // ReferenceError — total is not visible here
Block scope
Variables declared with let and const are confined to the nearest pair of curly braces — a loop body, an if, or a standalone block. (This is a key difference from var, which ignores blocks.)
function processUsers(users) {
const valid = [];
for (let i = 0; i < users.length; i++) {
const user = users[i]; // block-scoped to the loop
if (user.active) {
let message = `Processing ${user.name}`; // block-scoped to the if
console.log(message);
valid.push(user);
}
// console.log(message); // ReferenceError — out of the if block
}
return valid;
}
📖 Lexical scope
JavaScript uses lexical (static) scoping: a function's scope is fixed by where it is written in the source, not where it is called. An inner function can always see the variables of the functions it is nested inside.
The Scope Chain
When you use a variable, JavaScript looks for it in the current scope. If it isn't there, it looks in the enclosing scope, then the next one out, all the way to global. This series of links is the scope chain. The search only ever goes outward, never inward.
const globalV = 'global';
function outer() {
const outerV = 'outer';
function inner() {
const innerV = 'inner';
console.log(innerV, outerV, globalV); // all reachable
}
inner();
// console.log(innerV); // ReferenceError — cannot look inward
}
outer();
Variable shadowing
If an inner scope declares a variable with the same name as an outer one, the inner one shadows the outer — the outer stays untouched, it's just hidden while inside.
const value = 'global';
function outer() {
const value = 'outer'; // shadows global
function inner() {
const value = 'inner'; // shadows outer
console.log(value); // "inner"
}
inner();
console.log(value); // "outer"
}
outer();
console.log(value); // "global"
Hoisting & the TDZ
Hoisting is JavaScript registering declarations at the top of their scope during the creation phase, before any code runs. Different declarations hoist differently:
| Declaration | Hoisted? | Usable before its line? |
|---|---|---|
| Function declaration | Fully (name + body) | Yes |
var | Name only, value = undefined | Yes, but value is undefined |
let / const | Name only, uninitialized | No — throws (Temporal Dead Zone) |
// var: hoisted but undefined
console.log(a); // undefined
var a = 'hi';
// function declaration: fully hoisted
sayHi(); // "Hello!" works
function sayHi() { console.log('Hello!'); }
// let/const: in the Temporal Dead Zone until declared
// console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 'there';
⚠️ The Temporal Dead Zone (TDZ)
Between the top of a block and the line where a let/const is declared, the variable exists but cannot be touched. This is a feature: it catches use-before-declare mistakes that var would silently hide with undefined.
Execution Context & Call Stack
An execution context is the environment a piece of code runs in. There's one global context, and a new function context is created on every call. Each context is built in two phases:
- Creation phase — set up the variable environment, wire the scope chain, and determine
this. - Execution phase — run the code line by line, assigning values and calling functions.
JavaScript tracks these contexts with the call stack: calling a function pushes a context on top; returning pops it off. The context on top is the one currently running.
function multiply(a, b) { return a * b; }
function square(n) { return multiply(n, n); }
function printSquare(n) { console.log(square(n)); }
printSquare(4); // 16
// Stack grows: global → printSquare → square → multiply
// then unwinds as each returns
⚠️ Stack overflow
Recursion with no base case keeps pushing contexts until the stack's limit is hit:
function boom() { boom(); } // RangeError: Maximum call stack size exceeded
The this Keyword
The value of this is decided by how a function is called, not where it's written (with one exception — arrow functions). There are five rules:
1. Default binding
function show() { console.log(this); }
show(); // global object (or undefined in strict mode)
2. Implicit binding (method call)
const user = {
name: 'Alice',
greet() { console.log(this.name); }
};
user.greet(); // "Alice" — this is user
const fn = user.greet;
fn(); // undefined — now a plain call
3. Explicit binding (call / apply / bind)
function introduce(greeting) { console.log(`${greeting}, I'm ${this.name}`); }
const alice = { name: 'Alice' };
introduce.call(alice, 'Hello'); // Hello, I'm Alice
introduce.apply(alice, ['Hi']); // Hi, I'm Alice
const boundIntro = introduce.bind(alice);
boundIntro('Hey'); // Hey, I'm Alice
4. Constructor binding (new)
function User(name) { this.name = name; }
const u = new User('Alice');
console.log(u.name); // "Alice" — this is the new instance
5. Arrow functions (lexical this)
Arrow functions ignore all of the above and inherit this from where they're defined — the fix for callbacks that lose their object:
const team = {
name: 'Awesome Team',
members: ['Alice', 'Bob'],
showBroken() {
this.members.forEach(function (m) {
console.log(`${m} in ${this.name}`); // this.name is undefined
});
},
showFixed() {
this.members.forEach(m => {
console.log(`${m} in ${this.name}`); // this is team ✓
});
}
};
team.showFixed(); // "Alice in Awesome Team", "Bob in Awesome Team"
💡 Fixing "lost this" three ways
Inside an async callback you can preserve this with (1) an arrow function, (2) saving const self = this;, or (3) .bind(this). The arrow function is the modern default.
Closures
A closure is formed when an inner function keeps access to variables from its outer function even after that outer function has returned. The inner function "remembers" the environment it was born in. Closures are a direct consequence of lexical scoping plus the scope chain.
the outer variables]
function createGreeting(greeting) {
return function (name) {
return `${greeting}, ${name}!`; // remembers `greeting`
};
}
const sayHello = createGreeting('Hello');
const sayHi = createGreeting('Hi');
console.log(sayHello('Alice')); // "Hello, Alice!"
console.log(sayHi('Bob')); // "Hi, Bob!"
Practical use 1: private state
function createCounter() {
let count = 0; // private — unreachable from outside
return {
increment() { return ++count; },
decrement() { return --count; },
getValue() { return count; }
};
}
const counter = createCounter();
counter.increment(); counter.increment();
console.log(counter.getValue()); // 2
console.log(counter.count); // undefined — encapsulated
Practical use 2: function factories
function createMultiplier(factor) {
return n => n * factor;
}
const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5), triple(5)); // 10 15
⚠️ The classic loop-and-closure gotcha
With var, every closure in a loop shares one variable and ends up seeing its final value. Fix it by using let, which creates a fresh binding per iteration:
// ❌ var → all print 3
const bad = [];
for (var i = 0; i < 3; i++) bad.push(() => console.log(i));
bad.forEach(fn => fn()); // 3, 3, 3
// ✅ let → fresh binding each time
const good = [];
for (let j = 0; j < 3; j++) good.push(() => console.log(j));
good.forEach(fn => fn()); // 0, 1, 2
Hands-on Exercise
🏋️ A Private Bank Account
Objective: Use a closure to protect data that can only be changed through a controlled API.
Instructions:
- Write
createAccount(initial)that keeps a privatebalance. - Return
deposit(amount),withdraw(amount), andgetBalance(). - Reject withdrawals larger than the balance and negative amounts.
- Prove from the outside that
balancecannot be read or set directly.
💡 Hint
Declare let balance = initial; inside createAccount. Because only the returned methods close over it, nothing else in your program can reach that variable.
✅ Sample solution
function createAccount(initial = 0) {
let balance = initial; // private via closure
return {
deposit(amount) {
if (amount <= 0) return 'Amount must be positive';
balance += amount;
return balance;
},
withdraw(amount) {
if (amount <= 0) return 'Amount must be positive';
if (amount > balance) return 'Insufficient funds';
balance -= amount;
return balance;
},
getBalance() { return balance; }
};
}
const acct = createAccount(100);
console.log(acct.deposit(50)); // 150
console.log(acct.withdraw(30)); // 120
console.log(acct.withdraw(999)); // "Insufficient funds"
console.log(acct.getBalance()); // 120
console.log(acct.balance); // undefined — truly private
Why it works: each returned method closes over the same balance. The variable outlives the createAccount call because those methods still reference it, but no outside code has a name for it.
🎯 Quick Quiz
Question 1: When JavaScript can't find a variable in the current scope, where does it look next?
Question 2: What determines the value of this in a regular function?
Question 3: Why can a closure still read a variable after the outer function has returned?
Summary & What's Next
🎉 Key Takeaways
- JavaScript has global, function, and block scope; the scope chain resolves names outward only.
- Hoisting lifts declarations;
let/constsit in the temporal dead zone until declared. - Each call creates an execution context tracked on the call stack.
thisdepends on the call site for regular functions; arrow functions inherit it lexically.- Closures remember their birth scope — the basis of private state and function factories.
📚 Further Reading
🚀 What's Next?
You've now covered how to declare functions, feed them data, and reason about where their variables live. Time to put it all together in the Weekend Project: JavaScript Fundamentals, where you'll build something real from these building blocks.