ข้ามไปที่เนื้อหา
Clinford Jordano Valencia
บทความทั้งหมด

JavaScript

What Happens When a Function Runs: Execution Contexts and Closures

Execution contexts, lexical environments, the scope chain and closures, explained as one model, with the loop and memory pitfalls it explains.

  • อ่าน 7 นาที

หน้านี้ยังไม่มีฉบับภาษาไทย จึงแสดงต้นฉบับภาษาอังกฤษแทน

Every function call creates an execution context: a record of where execution is, which variables exist and what this is. Variables live in environments linked in a chain that follows how your code is nested on the page, not how it is called. A closure is what you get when a function outlives the call that created it and keeps that link. Hoisting, scope, closures and the classic “loop prints 3, 3, 3” bug are all the same model, and this post walks through it with diagrams.

Why It Matters

Closures power callbacks, event handlers, React hooks, memoization, private state and module patterns. When they go wrong, you get:

  • stale values in a setTimeout
  • a React effect that reads an old prop
  • memory that never gets freed

All of these become predictable once you can picture which environment a function holds.

The Call Stack and Execution Contexts

When JavaScript starts running a script or module, it creates a global (or module) execution context. Each function call pushes a new context onto the call stack; returning pops it off:

function total(items) {
  return items.reduce(add, 0);
}
function add(sum, n) {
  return sum + n;
}
total([1, 2, 3]);
  1. Module context calls total
  2. total context calls reduce
  3. reduce (built in) calls add for each item
  4. add context returns, popped off the stack

Each context has three main parts:

  • a lexical environment: the variables declared in that call
  • a reference to the outer environment
  • a this value

The stack has a size limit, and infinite recursion ends with RangeError: Maximum call stack size exceeded.

Creation, Then Execution

When a context is created, the engine first creates bindings for every declaration in that scope, then runs the code. That is what “hoisting” really means:

DeclarationBinding created withUsing it before the line runs
function f() {}The whole functionWorks
var xundefinedGives undefined
let x, const x, class XNothing yet (uninitialized)ReferenceError (temporal dead zone)

The kinds of scope (global, module, function and block), shadowing and the temporal dead zone each have more rules and traps than fit here. They have their own post: scope, hoisting and the temporal dead zone.

The Scope Chain

Every environment points to its outer environment, the one where its code was written. Looking up a name walks outward until it is found:

Module environmentx = "global" and outerouter() call environmentx = "outer" and innerinner() call environment(no x here)lookup of x finds "outer" first
Lexical scope: each environment points to the one it was written inside. Name lookup walks outward.
let x = 'global';
function outer() {
  let x = 'outer';
  return function inner() {
    return x;
  };
}
outer()(); // 'outer'

Blocks such as if, for and { … } add environments of their own for let, const and class, so the chain often has more links than there are functions. This is lexical scope: inner looks in the environment where it was written, regardless of who calls it. The one thing that does depend on the call is this, covered in JavaScript’s this keyword, four rules and one exception.

A function can use a variable declared below it, as long as the line has run before the function is called:

function show() {
  return y;
}
let y = 'defined later';
show(); // 'defined later'

Closures: Functions That Keep Their Environment

Normally a call’s environment is discarded when the call returns. But if a function created inside it is still reachable (returned, stored or passed as a callback), that function keeps a link to the environment, and so the environment stays alive:

function makeCounter() {
  let count = 0;
  return {
    inc: () => ++count,
    get: () => count,
  };
}
 
const a = makeCounter();
const b = makeCounter();
a.inc();
a.inc();
b.inc();
a.get(); // 2
b.get(); // 1

Each call to makeCounter creates a separate environment with its own count. Both functions returned from one call share that one environment, which is how they see each other’s changes. This is the classic way to get truly private state.

Closures You Already Use

Many everyday helpers are closures that keep a little private state between calls. once remembers whether it has run:

function once(fn) {
  let called = false;
  let result;
  return (...args) => {
    if (!called) {
      called = true;
      result = fn(...args);
    }
    return result;
  };
}
 
const init = once(() => console.log('initialized'));
init(); // logs 'initialized'
init(); // does nothing

debounce remembers the pending timer, so only the last call in a burst runs:

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}
 
const search = debounce((query) => console.log('search', query), 300);
search('r');
search('re');
search('react'); // only 'search react' runs, 300 ms after the last call

Each call to once or debounce creates a fresh environment, so two debounced functions never share a timer.

The Loop That Prints the Same Number

const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
withVar.map((f) => f()); // [3, 3, 3]
 
const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
withLet.map((f) => f()); // [0, 1, 2]

var is function-scoped, so there is one i shared by all three closures, and it is 3 when they run. let in a for loop creates a fresh binding for each iteration, so each closure captures its own j.

Stale Closures in React

The same rule explains “stale” values in hooks. A callback created during one render closes over that render’s props and state:

function Timer() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000); // count is always 0 here
    return () => clearInterval(id);
  }, []);
}

The effect ran once, so its closure holds the count from the first render. Use the updater form (setCount((c) => c + 1)) or list count as a dependency.

Closures and Memory

An environment stays in memory as long as something can reach a closure that uses it. That is normally exactly what you want, but long-lived holders turn it into a leak:

  1. A listener or timer stays registered
  2. It keeps a closure reachable
  3. The closure keeps its environment alive
  4. The environment keeps large objects alive

Engines only keep the variables closures can actually use, but closures created in the same scope can share one environment, so a small long-lived closure may keep a large object alive that only a sibling closure used. The fix is the same as for any leak:

  • remove listeners (an AbortController makes this easy)
  • clear timers
  • avoid capturing large objects in long-lived callbacks

The memory management post covers how to find these leaks.

Going Deeper: What the Engine Actually Keeps

The diagrams above describe the language. Engines implement it more cleverly, and knowing how explains both the performance and the memory behaviour of closures.

Call stackmakeCounter() (popped)module: aHeap{ inc, get }incgetenvironment: count = 2
After makeCounter returns, its stack frame is gone, but the environment holding count lives on the heap because two functions still point to it.
  • Only captured variables move to the heap. V8 analyses each function when it is compiled. Variables no inner function uses stay in fast stack slots and vanish when the call returns. Variables an inner function does use go into a heap-allocated context object that outlives the call.
  • Siblings share one context. All closures created in the same scope point to the same context object. That is why a small, long-lived callback can keep a large object alive if any sibling closure in that scope used it (the leak described above).
  • You can inspect it. In Chrome DevTools, expand a function in the console or the Scope panel and look at its [[Scopes]]: it lists exactly which environments, and which variables, the function keeps.

Closures, classes with #private fields and module scope can all hide state. Which to use is mostly a design choice:

ApproachGood forTrade-off
Closure (factory function)Small units of private state: once, debounce, counters, hooksA new set of functions per instance; no instanceof
Class with #private fieldsMany instances that share methods on the prototypeMethods need this, so passing them as callbacks requires binding
Module scopeOne shared instance for the whole programA hidden singleton: hard to reset in tests

Creating closures is cheap, but not free. In a hot loop or a React render, a new function is a new identity every time, which defeats memoization (React.memo, dependency arrays) rather than costing much CPU. Reach for useCallback or hoisting a function out of the loop when identity matters, not by default.

Test Yourself

Predict the output, then open the answer. Every answer was checked by running the code.

โจทย์ที่ 1

Two Counters

function makeCounter() {
  let n = 0;
  return () => ++n;
}
const a = makeCounter();
const b = makeCounter();
a();
a();
console.log(a(), b());
ดูคำตอบ

3 1

  • Each call to makeCounter creates a new environment with its own n.
  • a has been called three times in total, b once; they never share state.

โจทย์ที่ 2

A Variable That Changes Later

let greeting = 'Hello';
const greet = (name) => greeting + ', ' + name;
greeting = 'Hi';
console.log(greet('Ada'));
ดูคำตอบ

Hi, Ada

  • A closure captures the variable, not its value at the time the function was created.
  • When greet runs, it reads the current value of greeting.

โจทย์ที่ 3

Fixing the var Loop the Old Way

const fns = [];
for (var i = 0; i < 3; i++) fns.push(((j) => () => j)(i));
console.log(fns.map((f) => f()));
ดูคำตอบ

[ 0, 1, 2 ]

  • The immediately invoked function runs once per iteration and receives the current i as its own parameter j, a new variable each time.
  • Before let existed, this was the standard fix for the “loop prints the same number” bug; today, let in the loop header does the same thing.

โจทย์ที่ 4

Private by Construction

function account() {
  let balance = 0;
  return { deposit: (x) => (balance += x), read: () => balance };
}
const acct = account();
const { deposit, read } = acct;
deposit(50);
console.log(read(), acct.balance);
ดูคำตอบ

50 undefined

  • deposit and read share one environment, so they see each other’s changes.
  • They keep working after destructuring, because they don’t depend on this.
  • balance is not a property of the returned object: the only way to reach it is through the two functions. That is genuine privacy, without classes.

โจทย์ที่ 5

Separate Onces

function once(fn) {
  let done = false;
  return (...args) => {
    if (done) return 'skipped';
    done = true;
    return fn(...args);
  };
}
const init = once(() => 'ran');
const again = once(() => 'ran');
console.log(init(), init(), again());
ดูคำตอบ

ran skipped ran

  • Each call to once creates its own done flag.
  • init remembers it has run; again is a different closure over a different environment, so it still runs once.

Key Takeaways

  • Each call creates an execution context on the call stack, with its own environment of variables and a link to the outer environment.
  • Bindings are created before code runs: functions are ready, var is undefined, let, const and class are uninitialized.
  • Scope is lexical: a function looks names up where it was written, not where it is called.
  • A closure is a function that keeps its outer environment alive; it captures variables, not values.
  • Long-lived closures (listeners, timers, caches) keep their environments, and everything in them, in memory.

Sources

แท็ก

คำถามที่พบบ่อย

Does a closure copy the variables it uses?

NoIt keeps a reference to the environment that holds them, so it sees later changes and can make changes others see. To capture a value at a moment in time, copy it into a new variable, such as a let inside the loop body.

Is scope decided when a function is called or when it is written?

When it is written. JavaScript uses lexical scope, so a function looks up names in the place it was defined, wherever it is later called from. The value of this is the exception, because it depends on the call.

Do closures cause memory leaks?

NoNot by themselves. They leak only when something long-lived keeps the closure itself, such as an event listener, a timer or a cache. The closure keeps its environment reachable, and with it any large objects there. Remove listeners and clear timers when you are done.

What is the difference between an execution context and a scope?

A scope is a region of code where names are visible, decided when the code is written. An execution context is created each time code runs and holds the environments, this and other state for that particular call.

Are closures slower than classes?

NoNot in a way that matters for most code. Each closure is a separate function object, so creating thousands of instances uses more memory than sharing methods on a prototype. Choose based on design, and measure hot paths.

When should I use #private fields instead of closures?

When you create many instances that should share methods, or need instanceof and inheritance. Closures suit small factories and helpers such as once, debounce and React hooks.

Why does React create new functions on every render?

Each render calls your component function again, so every arrow function inside it is a new closure that sees that render’s props and state. That is cheap, but the new identity can defeat memoization; useCallback keeps one identity while its dependencies don’t change.

Why does my loop print the same number three times?

Because a var loop has one variable shared by every callback, and it already holds its final value when they run. Use let in the loop header: it creates a fresh binding for each iteration, so every callback keeps its own value.