JavaScript Closures, Scope & this — Lexical Environments, Loop Bugs, and Binding Rules
Lexical scope, closures, the var-in-loop bug, memory tradeoffs, and this binding — with an interactive demo and real frontend patterns.
You have debugged a React class component where this was undefined inside a callback {Bạn từng debug component React class mà this là undefined trong callback}. You have seen a for loop schedule three timeouts that all log 3 {Bạn từng thấy vòng for lên lịch ba timeout đều log 3}. Both problems look mysterious until you separate where variables live (lexical scope and closures) from what this points to (call-site binding) {Cả hai trông bí ẩn cho đến khi bạn tách biến sống ở đâu (lexical scope và closure) khỏi this trỏ tới đâu (call-site binding)}.
This post is for engineers who write production JavaScript daily and want a precise mental model — not folklore {Bài này dành cho engineer viết JavaScript production hàng ngày và muốn mental model chính xác — không phải đồn đại}. We cover scope chains, closures as reachability edges, the classic loop bug and its fixes, memory tradeoffs, and this rules in priority order {Chúng ta đi qua scope chain, closure như cạnh reachability, bug loop kinh điển và cách sửa, tradeoff bộ nhớ, và quy tắc this theo thứ tự ưu tiên}.
Open the full demo {Mở demo đầy đủ}: /tools/js-closures-demo/.
Lexical scope and the scope chain {Lexical scope và scope chain}
Lexical scope means variable visibility is determined by where code is written, not where it is called {Lexical scope nghĩa là visibility biến được xác định bởi nơi code được viết, không phải nơi nó được gọi}. When the engine parses a function, it records which outer bindings are visible — that static structure is the scope chain {Khi engine parse một function, nó ghi lại binding outer nào visible — cấu trúc tĩnh đó là scope chain}.
const globalMsg = 'global';
function outer() {
const outerMsg = 'outer';
function inner() {
const innerMsg = 'inner';
// Resolves: innerMsg → outerMsg → globalMsg → null
console.log(innerMsg, outerMsg, globalMsg);
}
return inner;
}
Each function invocation creates an execution context with two key parts {Mỗi lần gọi function tạo execution context với hai phần chính}:
| Part | Role |
|---|---|
| Variable environment | let/const/class bindings for this scope; also parameters and var in function scope |
| Lexical environment | The environment record plus a reference to the outer lexical environment (parent link) |
Lookup walks the chain: inner → outer → … → global → null {Lookup duyệt chain: inner → outer → … → global → null}. If no binding is found, ReferenceError is thrown in strict resolution paths {Nếu không tìm thấy binding, ReferenceError được ném trên đường resolve strict}.
Block vs function scope {Block vs function scope}:
varis function-scoped (or global if declared at top level) {varlà function-scoped (hoặc global nếu khai báo top level)}.letandconstare block-scoped — each\{ \}pair can host its own bindings {letvàconstlà block-scoped — mỗi cặp\{ \}có thể chứa binding riêng}.
var, let, const, hoisting, and the Temporal Dead Zone {var, let, const, hoisting, và Temporal Dead Zone}
All three declarations are hoisted in the sense that the engine registers them before the line runs {Cả ba declaration đều được hoist theo nghĩa engine đăng ký chúng trước khi dòng chạy}. What differs is when you may read or write them {Khác biệt nằm ở khi nào bạn được đọc/ghi}.
console.log(a); // undefined — var is hoisted and initialized to undefined
var a = 1;
console.log(b); // ReferenceError — let is hoisted but in TDZ
let b = 2;
| Declaration | Scope | Hoisted? | Initial value before line | TDZ? |
|---|---|---|---|---|
var | Function / global | Yes | undefined | No |
let | Block | Yes | Uninitialized | Yes — access throws |
const | Block | Yes | Must be assigned on line | Yes — access throws |
The Temporal Dead Zone (TDZ) is the span from entering a scope until the declaration line executes {Temporal Dead Zone (TDZ) là khoảng từ khi vào scope đến khi dòng declaration chạy}. The binding exists in the environment record but is marked uninitialized; reading it is illegal {Binding tồn tại trong environment record nhưng đánh dấu chưa khởi tạo; đọc nó là bất hợp pháp}. This is why typeof undeclaredVar returns "undefined" but typeof letInTDZ throws if let letInTDZ appears later in the same scope {Đó là lý do typeof undeclaredVar trả "undefined" nhưng typeof letInTDZ throw nếu let letInTDZ xuất hiện sau trong cùng scope}.
const requires an initializer and forbids rebinding the identifier — the value may still be mutable if it is an object {const bắt buộc initializer và cấm rebind identifier — giá trị vẫn có thể mutable nếu là object}:
const cfg = { retries: 3 };
cfg.retries = 5; // OK — mutating the object
// cfg = {} // TypeError — rebinding forbidden
Closures — definition via the value graph {Closure — định nghĩa qua value graph}
A closure is a function plus the retained lexical environment needed to resolve its free variables when invoked later {Closure là function cộng lexical environment được giữ cần để resolve free variable khi gọi sau}. In engine terms, the inner function is a heap object with an internal [[Environment]] link to outer bindings {Theo engine, inner function là heap object có link nội bộ [[Environment]] tới binding outer}.
Think in the value graph from roots {Nghĩ theo value graph từ root}: if a callback, timer, or DOM listener keeps a function alive, the entire captured environment subgraph stays reachable — even variables that are never read again {Nếu callback, timer, hoặc DOM listener giữ function sống, toàn bộ subgraph environment bị capture vẫn reachable — kể cả biến không bao giờ đọc lại}.
function makeCounter() {
let count = 0; // lives in outer lexical env
return function increment() {
count += 1;
return count;
};
}
const a = makeCounter();
const b = makeCounter();
a(); // 1
a(); // 2
b(); // 1 — separate environment, separate count
Each call to makeCounter() allocates a fresh environment record with its own count {Mỗi lần gọi makeCounter() allocate environment record mới với count riêng}. The returned function closes over that record — classic data privacy without classes {Function trả về close over record đó — data privacy kinh điển không cần class}.
Practical closure patterns in frontend code {Pattern closure thực tế trong frontend}
Module pattern and encapsulation {Module pattern và encapsulation}
Before ES modules were universal, IIFEs returned public APIs while hiding state {Trước khi ES module phổ biến, IIFE trả API public trong khi ẩn state}:
const authStore = (() => {
let token = null;
return {
setToken(t) { token = t; },
getToken() { return token; },
clear() { token = null; },
};
})();
Modern code prefers export / import, but the idea persists: only closures expose what you intend {Code hiện đại ưu tiên export / import, nhưng ý tưởng vẫn vậy: chỉ closure expose những gì bạn định}.
Memoization {Memoization}
function memoize(fn) {
const cache = new Map();
return function memoized(...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
The cache Map lives in the outer environment — every call to memoized reads the same table {Cache Map sống trong outer environment — mọi lần gọi memoized đọc cùng bảng}.
Event handlers and partial application {Event handler và partial application}
function onFilterChange(fieldName) {
return (event) => {
queryState[fieldName] = event.target.value;
refetch();
};
}
emailInput.addEventListener('input', onFilterChange('email'));
Each listener closes over a different fieldName without global temporaries {Mỗi listener close over fieldName khác nhau không cần biến tạm global}.
Currying and once {Currying và once}
function once(fn) {
let called = false;
let result;
return function (...args) {
if (!called) {
called = true;
result = fn.apply(this, args);
}
return result;
};
}
const initAnalytics = once(() => loadScript('/analytics.js'));
The called flag is private — callers cannot reset it without a new wrapper {Flag called là private — caller không reset được trừ khi tạo wrapper mới}.
The var-in-loop closure bug and three fixes {Bug closure var-in-loop và ba cách sửa}
This pattern broke countless interviews and production logs {Pattern này làm hỏng vô số phỏng vấn và log production}:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 3, 3, 3
Why {Vì sao}: var i is one binding for the entire function/global scope {var i là một binding cho cả function/global scope}. Each arrow function closes over that single binding; by the time callbacks run, the loop has finished and i === 3 {Mỗi arrow function close over binding duy nhất đó; khi callback chạy, vòng lặp đã xong và i === 3}.
Fix 1 — use let (per-iteration binding) {Fix 1 — dùng let (binding mỗi iteration)}
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Logs: 0, 1, 2
let in a for head creates a new binding per iteration in modern engines {let trong for head tạo binding mới mỗi iteration trên engine hiện đại}. Each callback captures a different i {Mỗi callback capture i khác nhau}.
Fix 2 — IIFE capture (pre-ES2015) {Fix 2 — IIFE capture (trước ES2015)}
for (var i = 0; i < 3; i++) {
((j) => {
setTimeout(() => console.log(j), 100);
})(i);
}
The IIFE parameter j is a fresh binding per invocation {Tham số IIFE j là binding mới mỗi lần gọi}.
Fix 3 — factory argument {Fix 3 — tham số factory}
for (var i = 0; i < 3; i++) {
setTimeout(((n) => () => console.log(n))(i), 100);
}
Same idea: freeze the value at scheduling time {Cùng ý: đóng băng giá trị lúc schedule}.
Run both variants in the demo above {Chạy cả hai biến thể trong demo phía trên}. Seeing
3,3,3vs0,1,2side by side beats memorizing rules {Thấy3,3,3vs0,1,2cạnh nhau hiệu quả hơn học thuộc quy tắc}.
Memory implications — closures are features, retention is the cost {Hệ quả bộ nhớ — closure là tính năng, retention là chi phí}
Closures are not leaks by themselves {Closure không phải leak tự thân}. They become problems when long-lived hosts (global singletons, forgotten listeners, module-level caches) retain large captured graphs unnecessarily {Chúng thành vấn đề khi host sống lâu (singleton global, listener quên xóa, cache module-level) giữ subgraph capture lớn không cần thiết}.
Common frontend footguns {Footgun frontend phổ biến}:
- Attaching a closure that captures a huge DOM subtree or API payload to
windowor a long-lived store {Gắn closure capture DOM subtree hoặc API payload lớn lênwindowhoặc store sống lâu} - Replacing listeners without removing old ones — each holds its environment {Thay listener mà không remove cũ — mỗi cái giữ environment}
- Debounce/throttle helpers that close over stale heavy objects {Helper debounce/throttle close over object nặng đã stale}
Break retention by narrowing capture: extract only primitives/IDs, null out references when done, remove listeners in cleanup {Cắt retention bằng thu hẹp capture: chỉ extract primitive/ID, gán null reference khi xong, remove listener trong cleanup}. For a full GC mental model and DevTools workflow, see JavaScript Memory Management & Leak Hunting {Với mental model GC đầy đủ và workflow DevTools, xem JavaScript Memory Management & Leak Hunting}.
this — a separate mechanism {this — cơ chế riêng biệt}
this is not lexical (except arrow functions — see below) {this không lexical (trừ arrow function — xem bên dưới)}. It is determined by how a function is invoked {Nó được xác định bởi cách function được gọi}. Confusing this with closure scope causes most “it worked in the method but not in the callback” bugs {Nhầm this với closure scope gây hầu hết bug “chạy trong method nhưng không trong callback”}.
Binding rules in priority order {Quy tắc binding theo thứ tự ưu tiên}
| Priority | Call form | this value |
|---|---|---|
| 1 (highest) | new Fn() | Newly created object |
| 2 | fn.call(ctx) / fn.apply(ctx) / fn.bind(ctx) | Explicit ctx (bound functions ignore later overrides) |
| 3 | obj.method() | obj |
| 4 (lowest) | fn() standalone | Strict: undefined; non-strict: global object |
function show() {
return this;
}
const box = { show };
show(); // undefined (strict)
box.show(); // box
show.call({ id: 1 }); // { id: 1 }
new show(); // {} (new object, unless constructor returns object)
bind creates a wrapper with a permanent this and optional partial args {bind tạo wrapper với this cố định và optional partial args}:
const bound = show.bind({ tag: 'bound' });
bound.call({ tag: 'ignored' }); // still { tag: 'bound' }
Arrow functions and lexical this {Arrow function và lexical this}
Arrow functions do not have their own this binding {Arrow function không có binding this riêng}. They lexically capture this from the enclosing scope at creation time {Chúng capture this lexical từ enclosing scope lúc tạo}.
const timer = {
seconds: 0,
start() {
setInterval(() => {
this.seconds += 1; // `this` is timer — lexical from start()
}, 1000);
},
};
Compare with a regular function callback {So với callback function thường}:
startBroken() {
setInterval(function tick() {
this.seconds += 1; // `this` is global/window (or undefined in strict module)
}, 1000);
}
Use arrows for callbacks that need outer this {Dùng arrow cho callback cần this outer}. Do not use arrows as object methods if you need dynamic this {Không dùng arrow làm object method nếu cần this động}, or as constructors — new arrowFn() is a syntax error {hoặc constructor — new arrowFn() là syntax error}.
Losing this in callbacks — the React class handler problem {Mất this trong callback — bài toán React class handler}
Class methods are usually plain functions on the prototype {Method class thường là function thường trên prototype}. Passing them bare loses the receiver {Truyền trần làm mất receiver}:
class SearchBox extends React.Component {
constructor(props) {
super(props);
this.state = { q: '' };
this.handleChange = this.handleChange.bind(this); // fix A
}
handleChange(e) {
this.setState({ q: e.target.value }); // needs component as `this`
}
render() {
return (
<input onChange={this.handleChange} />
);
}
}
Fixes engineers use in the wild {Cách sửa engineer dùng thực tế}:
| Fix | Tradeoff |
|---|---|
bind in constructor | One function per instance; explicit |
| Class field arrow | Lexical this; instance field; slightly larger per instance |
| Inline arrow in JSX | New function each render — usually fine, watch memoized children |
Function components avoid the problem entirely — hooks close over state in lexical scope, not this {Function component tránh hẳn vấn đề — hook close over state trong lexical scope, không phải this}.
this in DOM event handlers {this trong DOM event handler}
With addEventListener, the listener’s this is the element that received the event (unless passive arrow or bound function) {Với addEventListener, this của listener là element nhận event (trừ arrow passive hoặc bound function)}:
button.addEventListener('click', function onClick() {
console.log(this === button); // true
});
button.addEventListener('click', () => {
console.log(this); // lexical — likely undefined in module strict
});
In jQuery-style APIs, this often refers to the matched DOM node inside event callbacks {Trong API kiểu jQuery, this thường là DOM node khớp trong event callback}. Always check whether the API uses call-site this or passes the target as an argument {Luôn kiểm tra API dùng call-site this hay truyền target làm argument} — modern APIs favor explicit arguments over magic this {API hiện đại ưu tiên argument rõ ràng hơn this ma thuật}.
Putting it together — a decision checklist {Tổng hợp — checklist quyết định}
When debugging scope vs this {Khi debug scope vs this}:
- Variable not updating / all callbacks see the same value? → Check loop declaration (
letvsvar), shared binding, async timing {Biến không cập nhật / mọi callback thấy cùng giá trị? → Kiểm tra khai báo loop (letvsvar), binding chung, timing async} thisisundefinedorwindowin a callback? → Method extracted without bind; use arrow or explicit receiver {thislàundefinedhoặcwindowtrong callback? → Method bị extract không bind; dùng arrow hoặc receiver rõ}- Memory grows after navigation? → Long-lived closure still referenced; audit listeners and module singletons {Memory tăng sau navigation? → Closure sống lâu vẫn được reference; audit listener và singleton module}
- Need private state? → Closure or module scope; avoid polluting globals {Cần state private? → Closure hoặc module scope; tránh ô nhiễm global}
Principal insight {Nhận thức cấp principal}: Closures answer “which variables can this function see?” {Closure trả lời “function này thấy biến nào?”}.
thisanswers “who is calling me right now?” {thistrả lời “ai đang gọi tôi lúc này?”}. Keep the questions separate and half of JavaScript’s “weird” behavior becomes predictable {Tách hai câu hỏi và một nửa hành vi “kỳ lạ” của JavaScript trở nên dự đoán được}.
Further reading {Đọc thêm}
- JavaScript Memory Management & Leak Hunting — reachability, retained size, DevTools {reachability, retained size, DevTools}
- JavaScript Generators & yield — pausable functions with preserved local state {function có thể dừng với local state được giữ}
- MDN: Closures,
this