jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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à thisundefined 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}:

PartRole
Variable environmentlet/const/class bindings for this scope; also parameters and var in function scope
Lexical environmentThe 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}: var is function-scoped (or global if declared at top level) {var là function-scoped (hoặc global nếu khai báo top level)}. let and const are block-scoped — each \{ \} pair can host its own bindings {letconst là 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;
DeclarationScopeHoisted?Initial value before lineTDZ?
varFunction / globalYesundefinedNo
letBlockYesUninitializedYes — access throws
constBlockYesMust be assigned on lineYes — 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 imộ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,3 vs 0,1,2 side by side beats memorizing rules {Thấy 3,3,3 vs 0,1,2 cạ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 window or a long-lived store {Gắn closure capture DOM subtree hoặc API payload lớn lên window hoặ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}

PriorityCall formthis value
1 (highest)new Fn()Newly created object
2fn.call(ctx) / fn.apply(ctx) / fn.bind(ctx)Explicit ctx (bound functions ignore later overrides)
3obj.method()obj
4 (lowest)fn() standaloneStrict: 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ế}:

FixTradeoff
bind in constructorOne function per instance; explicit
Class field arrowLexical this; instance field; slightly larger per instance
Inline arrow in JSXNew 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}:

  1. Variable not updating / all callbacks see the same value? → Check loop declaration (let vs var), 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 (let vs var), binding chung, timing async}
  2. this is undefined or window in a callback? → Method extracted without bind; use arrow or explicit receiver {thisundefined hoặc window trong callback? → Method bị extract không bind; dùng arrow hoặc receiver rõ}
  3. 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}
  4. 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?”}. this answers “who is calling me right now?” {this trả 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}