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]);- Module context calls
total totalcontext callsreducereduce(built in) callsaddfor each itemaddcontext 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
thisvalue
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:
| Declaration | Binding created with | Using it before the line runs |
|---|---|---|
function f() {} | The whole function | Works |
var x | undefined | Gives undefined |
let x, const x, class X | Nothing 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:
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(); // 1Each 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 nothingdebounce 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 callEach 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:
- A listener or timer stays registered
- It keeps a closure reachable
- The closure keeps its environment alive
- 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
AbortControllermakes 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.
- 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:
| Approach | Good for | Trade-off |
|---|---|---|
| Closure (factory function) | Small units of private state: once, debounce, counters, hooks | A new set of functions per instance; no instanceof |
Class with #private fields | Many instances that share methods on the prototype | Methods need this, so passing them as callbacks requires binding |
| Module scope | One shared instance for the whole program | A 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
makeCountercreates a new environment with its ownn. ahas been called three times in total,bonce; 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
greetruns, it reads the current value ofgreeting.
チャレンジ 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
ias its own parameterj, a new variable each time. - Before
letexisted, this was the standard fix for the “loop prints the same number” bug; today,letin 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
depositandreadshare one environment, so they see each other’s changes.- They keep working after destructuring, because they don’t depend on
this. balanceis 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
oncecreates its owndoneflag. initremembers it has run;againis 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,
varisundefined,let,constandclassare 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
- V8 blog: Blazingly fast parsing, part 2: lazy parsing (how V8 finds which variables closures capture)
- MDN: Private properties
- ECMAScript specification: Execution contexts and Environment records
- MDN: Closures
- MDN: let: temporal dead zone
- React docs: Updating state based on the previous state
タグ
よくある質問
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.