Lesson 1.4.8 — Stale Closure
0. Metadata (Thông tin bài học)
| Field | Value |
|---|---|
| Stage | 1 |
| Module | 1.4 — Closures |
| Lesson | 1.4.8 — Stale Closure |
| Competency | C02 — JavaScript Runtime (C02.5 Closure) |
| Depth Target | L4 (Debug) |
| Prerequisites | Closure Formation (1.4.1), Closure Lifetime (1.4.2), Closure + Callback (1.4.6), Closure + Async (1.4.7), Variable Resolution (1.2.6) |
| Estimated Cognitive Load | High |
Out of Scope (Ngoài phạm vi)
- React render cycle, Hooks, dependency arrays và
useEffect(Stage 8) - Event Loop / Promise scheduling internals (Stage 3)
- Race condition và concurrency control (Stage 3)
- Garbage Collection internals và memory profiling (Stage 11)
- Framework-specific stale closure fixes
1. Why This Exists (Vì sao cần học)
Ở Lesson 1.4.7, bạn đã thấy async callback có thể chạy sau nhưng vẫn truy cập được captured bindings.
Bây giờ xuất hiện một bug tinh vi hơn:
Callback vẫn hoạt động đúng theo lexical scope, nhưng dữ liệu nó dùng không còn đại diện cho state mới nhất của hệ thống.
Ví dụ:
function createLogger(count) {
const message = `Count is ${count}`;
return function log() {
console.log(message);
};
}
let count = 0;
const log = createLogger(count);
count = 3;
log();Output:
Count is 0Global count hiện là 3, nhưng callback vẫn log "Count is 0".
Không có lỗi trong Closure. Không có variable resolution sai.
Callback đang đọc đúng message trong environment nơi nó được tạo.
Vấn đề là:
state used by closure
≠
latest state in systemĐây là nền tảng của Stale Closure.
Nếu không hiểu cơ chế này, bạn sẽ dễ:
- Sửa stale closure bằng trial-and-error.
- Nghĩ Closure "đóng băng mọi variable".
- Nhầm shared mutable binding với captured snapshot.
- Không phân biệt old invocation với current system state.
- Khó hiểu React stale closure ở Stage 8.
- Không nhận ra callback có thể đúng về lexical semantics nhưng sai về business timing/state.
2. Prerequisites (Yêu cầu đầu vào)
Trước khi học bài này, bạn phải:
- [ ] Giải thích được Closure Formation.
- [ ] Biết Closure giữ reference đến bindings/environment cần thiết.
- [ ] Biết callback có thể chạy sau khi creator function return.
- [ ] Phân biệt binding với value.
- [ ] Biết mỗi function invocation tạo local parameter/local bindings riêng.
- [ ] Phân biệt shared outer binding với per-invocation binding.
- [ ] Có thể trace creation site của callback.
Nếu thiếu, quay lại Lesson 1.4.1, 1.4.6 và 1.4.7 trước.
3. Learning Objectives (Mục tiêu học tập)
Sau bài này, bạn có thể:
- Giải thích stale closure bằng invocation + binding + system state.
- Phân biệt callback đọc shared live binding với callback giữ data của một older invocation.
- Dự đoán khi callback có thể dùng state cũ.
- Debug một stale closure mà lexical resolution vẫn hoàn toàn đúng.
- Phân biệt captured binding, derived snapshot và latest state.
- Sửa stale-state bug bằng cách chọn đúng state source hoặc tạo callback mới đúng lifecycle.
- Chuẩn bị mental model cho React stale closure ở Stage 8.
4. Mental Model (Mô hình tư duy)
Mental Model — Stale Closure
Stale Closure không có nghĩa Closure bị hỏng.
Closure vẫn resolve đúng bindings thuộc lexical environment nơi function được tạo.
Bug xuất hiện khi:
Closure keeps data from Environment A
↓
System later moves to State / Environment B
↓
Old callback still executes
↓
Callback uses data from A
↓
Data is stale relative to current system stateVí dụ:
function createLogger(count) {
const message = `Count is ${count}`;
return () => {
console.log(message);
};
}
let count = 0;
const log = createLogger(count);
count = 3;Mental model:
Global Environment
└── count = 3
createLogger(0) — old invocation
├── count = 0
├── message = "Count is 0"
└── log closure
↑
└── still reachablelog() không resolve global count.
Nó dùng message thuộc invocation createLogger(0).
Vì vậy:
Captured environment data: "Count is 0"
Latest global state: count = 3Staleness là mối quan hệ giữa data closure đang dùng và state mà system hiện coi là current.
Important Precision
Không nên biến Stale Closure thành rule:
"Closure luôn capture value tại thời điểm tạo."
Closure vẫn hoạt động theo binding/environment model.
Một callback chỉ trở nên stale khi binding/environment nó đang dùng đại diện cho older state so với state hiện tại của system.
Nếu callback capture một shared binding đang được update, nó có thể đọc value mới nhất của chính binding đó và không stale theo cách này.
5. Core Concepts (Các khái niệm cốt lõi)
Essential (Bắt buộc)
- Stale Closure: Closure vẫn dùng data từ một lexical environment/binding không còn đại diện cho current system state.
- Old Invocation: Invocation cũ tạo callback và bindings riêng.
- Current State: State mà system hiện tại coi là mới nhất.
- Derived Snapshot: Value được tính từ state tại một thời điểm rồi lưu vào binding khác.
- State Source: Binding/object/store mà callback thực sự đọc khi chạy.
- Callback Lifetime: Khoảng thời gian callback cũ còn reachable và có thể được gọi.
Supporting (Hỗ trợ)
- Parameter binding của mỗi invocation là riêng.
- Derived string/object có thể giữ data từ một thời điểm cũ.
- Tạo callback mới có thể tạo lexical environment mới với state mới hơn.
- Shared mutable binding có behavior khác per-invocation captured state.
Awareness (Biết tồn tại)
- React render tạo ra nhiều render invocations/environments; stale closure là vấn đề rất quan trọng ở Stage 8.
- Async operations có thể làm stale state trở thành race-like production bug.
- Fix strategy phụ thuộc ownership và lifecycle của state, không có một mẹo universal.
6. Worked Example (Ví dụ phân tích từng bước)
Worked Example — Callback đúng lexical scope nhưng dùng state cũ:
function createStatusLogger(status) {
const message = `Status: ${status}`;
return function logStatus() {
console.log(message);
};
}
let currentStatus = "idle";
const logStatus = createStatusLogger(currentStatus);
currentStatus = "loading";
currentStatus = "success";
logStatus();Step 1 — Global state ban đầu:
currentStatus = "idle"Step 2 — createStatusLogger(currentStatus) được gọi:
Giá trị "idle" được truyền vào parameter binding:
status → "idle"Sau đó:
const message = `Status: ${status}`;tạo binding:
message → "Status: idle"Step 3 — logStatus được tạo:
logStatus dùng:
messagenên closure giữ environment của invocation đó.
Step 4 — Creator return:
logStatus được return và vẫn reachable.
Outer invocation cũ tiếp tục là lexical source của callback.
Step 5 — System state thay đổi:
currentStatus = "loading";
currentStatus = "success";Global binding hiện tại:
currentStatus → "success"Nhưng callback không dùng binding này.
Step 6 — logStatus() chạy:
Variable Resolution của callback tìm:
message → "Status: idle"Output:
Status: idleStep 7 — Root mental model:
Old invocation data
message = "Status: idle"
↑
└── old callback
Current system state
currentStatus = "success"Callback không sai về lexical scope.
Nó stale vì state source của callback là dữ liệu từ old invocation.
7. Prediction Exercise (Bài tập dự đoán)
Đừng chạy code. Dự đoán output và chỉ rõ callback đang đọc binding nào.
Prediction 1 — Derived snapshot
function createLogger(count) {
const message = `Count: ${count}`;
return () => message;
}
let count = 0;
const log = createLogger(count);
count = 5;
console.log(log());Prediction 2 — Shared live binding
let count = 0;
const log = () => count;
count = 5;
console.log(log());Prediction 3 — Two invocations
function createLogger(count) {
return () => count;
}
let count = 0;
const oldLog = createLogger(count);
count = 5;
const newLog = createLogger(count);
console.log(oldLog());
console.log(newLog());[Đáp án & Giải thích]
Prediction 1:
- Output:
"Count: 0". logđọc bindingmessagecủa invocationcreateLogger(0). Globalcount = 5không thuộc lexical chain mà callback dùng chomessage.
- Output:
Prediction 2:
- Output:
5. - Callback capture shared global binding
count. Binding đó được update từ0sang5, nên callback đọc current value của cùng binding. Đây không phải stale snapshot case.
- Output:
Prediction 3:
- Output:
0rồi5. oldLogthuộc invocationcreateLogger(0)với parameter bindingcount = 0.newLogthuộc invocationcreateLogger(5)với parameter bindingcount = 5.- Hai callbacks dùng hai environments khác nhau.
- Output:
Transfer Check — Config version:
function createRequester(config) {
return () => {
return config.baseUrl;
};
}
let config = {
baseUrl: "/v1",
};
const oldRequest = createRequester(config);
config = {
baseUrl: "/v2",
};
console.log(oldRequest());Hãy trả lời:
oldRequestcapture global bindingconfighay parameter bindingconfig?- Parameter binding đó đang trỏ tới object nào?
- Reassign global
configsang object mới có đổi parameter binding cũ không? - Output là gì?
Gợi ý trả lời
oldRequestcapture parameter bindingconfigcủa invocationcreateRequester.- Binding đó trỏ tới object
{ baseUrl: "/v1" }. - Không. Reassign global
configchỉ đổi global binding sang object mới. - Output là
"/v1".
8. Implementation Lab (Bài lab thực hành)
Lab 1 — Guided: Tạo và quan sát stale snapshot
Implement:
function createMessageLogger(value) {
// TODO
}
let value = "A";
const log = createMessageLogger(value);
value = "B";
console.log(log()); // "Value: A"Yêu cầu:
- Tạo một derived
message. - Callback chỉ đọc
message. - Giải thích vì sao global
value = "B"không làmmessagecũ thay đổi.
[Đáp án tham khảo]
function createMessageLogger(value) {
const message = `Value: ${value}`;
return function () {
return message;
};
}- Giải thích:
messageđược tạo trong invocation với parametervalue = "A". Callback giữ bindingmessagecủa invocation đó. Globalvaluesau này là một binding khác.
Lab 2 — Partial Scaffold: Old callback vs new callback
Hoàn thiện:
function createReader(value) {
return function () {
// TODO
};
}
let state = 1;
const oldReader = createReader(state);
state = 2;
const newReader = createReader(state);
console.log(oldReader()); // 1
console.log(newReader()); // 2Sau đó vẽ:
oldReader → Environment A → value = ?
newReader → Environment B → value = ?[Đáp án tham khảo]
function createReader(value) {
return function () {
return value;
};
}Diagram:
oldReader → Environment A → value = 1
newReader → Environment B → value = 2- Giải thích: Mỗi call
createReadertạo parameter binding riêng. Old callback không tự chuyển sang environment của invocation mới.
Lab 3 — Independent: Chọn live state thay vì snapshot
Requirement:
Callback phải luôn đọc current value của một state holder.
Cho:
const state = {
value: 1,
};
function createReader() {
// TODO
}
const read = createReader();
state.value = 2;
console.log(read()); // 2
state.value = 3;
console.log(read()); // 3Constraints:
- Callback phải đọc current property khi được gọi.
- Không tạo derived snapshot trong creator.
- Không recreate callback giữa các updates.
[Một phương án tham khảo]
const state = {
value: 1,
};
function createReader() {
return function () {
return state.value;
};
}- Giải thích: Callback resolve shared binding
state, rồi đọc property hiện tại của object. Đây là một state-source strategy khác với per-invocation snapshot. - Lưu ý: Đây không phải universal fix cho mọi stale closure. Nó chỉ minh họa rằng fix phụ thuộc state ownership và source mà callback nên đọc.
9. Edge Cases (Các trường hợp ngoại lệ)
Edge Case 1 — Global shared binding không stale theo snapshot model
Stage example:
let count = 0;
function logLater() {
setTimeout(() => {
console.log(count);
}, 1000);
}Phân tích
Đoạn code này tự nó chưa tạo stale snapshot.
Callback resolve global binding count. Nếu global binding đó được update trước callback chạy, callback sẽ đọc current value của cùng binding.
Để tạo stale behavior, cần có một old binding / derived snapshot / old invocation không còn đại diện cho current system state.
Điểm này rất quan trọng để không biến "async callback" thành đồng nghĩa với "stale closure".
Edge Case 2 — Object mutation vs object replacement
function createReader(config) {
return () => config.mode;
}
let config = {
mode: "A",
};
const read = createReader(config);
config.mode = "B";
console.log(read());Phân tích
Output là "B".
Parameter binding config vẫn trỏ tới cùng object, và object đó bị mutate.
Nếu thay vì mutate ta reassign global config sang object mới, old callback vẫn giữ object cũ.
Cần phân biệt:
mutation of same object
vs
replacement with new objectEdge Case 3 — Recomputing derived value
function createLogger(state) {
return () => {
return `Count: ${state.count}`;
};
}Phân tích
Callback không giữ một derived string cố định. Nó recompute string mỗi lần chạy từ state.count.
Nếu state vẫn trỏ tới cùng mutable object và count thay đổi, callback có thể thấy property mới.
Staleness phụ thuộc state source + identity + lifecycle, không chỉ việc Closure tồn tại.
10. Debug Lab (Bài lab gỡ lỗi)
Debug Lab — Callback gửi request với config cũ
Symptom (Triệu chứng): System đã chuyển API từ /v1 sang /v2, nhưng một callback cũ vẫn gửi request tới /v1.
Reproduction (Tái hiện lỗi):
function createRequest(config) {
return function request() {
return config.baseUrl;
};
}
let config = {
baseUrl: "/v1",
};
const request = createRequest(config);
config = {
baseUrl: "/v2",
};
console.log(request()); // "/v1"Evidence (Bằng chứng):
- Global binding
configđược reassign sang object/v2. createRequestđã được gọi trước khi reassign.- Invocation cũ có parameter binding
config. - Parameter binding đó trỏ tới object
/v1. - Returned callback dùng parameter
config, không dùng globalconfig.
Hypothesis (Giả thuyết):
Callback vẫn giữ environment của old invocation và object /v1.
Global state mới không rewrite parameter binding cũ.
Verification (Xác minh):
Tạo callback mới sau config update:
const newRequest = createRequest(config);
console.log(request()); // "/v1"
console.log(newRequest()); // "/v2"Nếu hai callbacks trả khác nhau, hypothesis được xác nhận.
Root Cause (Nguyên nhân gốc rễ):
old callback
→ old invocation
→ old config reference
system current state
→ new global config binding value
→ new config objectCallback cũ stale relative to current system configuration.
Fix (Sửa):
Fix phụ thuộc lifecycle requirement.
Nếu callback phải gắn với config tại lúc tạo:
Không có bug — snapshot là intentional.Nếu callback phải luôn dùng latest config, có thể thiết kế state source khác:
let config = {
baseUrl: "/v1",
};
function createRequest() {
return function request() {
return config.baseUrl;
};
}hoặc recreate callback khi config thay đổi.
Prevention (Phòng ngừa):
- Xác định callback cần snapshot state hay latest state.
- Khi state được replace bằng object mới, kiểm tra callbacks cũ đang giữ reference nào.
- Khi debug stale value, trace creator invocation trước.
- Không sửa bằng cách "đưa hết ra global" nếu chưa xác định ownership.
- Ghi rõ lifecycle contract của callback.
11. Design Exercise (Bài tập thiết kế giải pháp)
Không áp dụng architecture design đầy đủ ở depth L4.
Thay vào đó, làm state freshness decision.
Bạn có callback cần gửi request và hai requirement khác nhau:
Requirement A — Snapshot
Request phải dùng config đúng tại thời điểm job được tạo.
Phù hợp với:
function createJob(config) {
return () => config.baseUrl;
}Requirement B — Latest
Callback khi chạy phải luôn dùng config hiện tại.
Có thể cần một shared/current state source:
function createJob() {
return () => currentConfig.baseUrl;
}Hãy trả lời:
1. State owner là ai?
2. Snapshot hay latest?
3. Callback lifetime bao lâu?
4. Config có mutate cùng object hay replace object?
5. Khi nào callback cần được recreate?Engineering Decision
Stale Closure không có "fix mặc định".
Trước khi sửa, phải biết requirement:
snapshot semantics
vs
latest-state semanticsMột callback dùng old state có thể là bug — hoặc có thể chính là behavior đúng.
12. Production Scenario (Tình huống thực tế)
Production Scenario — Delayed Search với query snapshot
Một UI tạo search callback:
function createSearchTask(query) {
return function runSearch() {
console.log("Searching:", query);
};
}User nhập:
"java"và task A được tạo.
Sau đó user đổi input thành:
"javascript"và task B được tạo.
Mental model:
Task A
└── query = "java"
Task B
└── query = "javascript"Nếu task A chạy sau task B, task A vẫn dùng "java".
Về Closure semantics, behavior đó hoàn toàn đúng.
Về product behavior, nó có thể là stale work.
Context: System có nhiều operations được tạo ở các thời điểm khác nhau.
Constraint: Older callback có thể chạy khi newer state đã tồn tại.
Production Question:
Callback nên đại diện cho state lúc nó được tạo, hay state mới nhất lúc nó chạy?
Đây là câu hỏi sẽ mở rộng thành:
- request race;
- cancellation;
- stale UI updates;
- React stale callbacks;
ở các Stage sau.
13. AI-assisted Exercise (Bài tập với AI)
Level B — Challenge (Thách thức)
- Tự trả lời trước: Stale Closure có phải vì Closure "copy variable" không?
- Hỏi AI: "Explain stale closure in JavaScript using lexical environments and separate old invocation state from latest system state."
- Inspect: AI có nói mọi closure đều snapshot value tại creation time không?
- Challenge: Đưa hai examples:
- callback capture shared global binding;
- callback capture parameter binding của old invocation.
- Verify: Yêu cầu AI dự đoán hai outputs và giải thích sự khác biệt bằng binding identity.
Gợi ý
AI answer đạt yêu cầu phải phân biệt:
shared binding updated
→ callback may read latest value
old invocation binding
→ callback keeps old invocation data
derived snapshot
→ callback may keep old computed valueĐáp án tham khảo
- Closure không tự động copy mọi value.
- Closure giữ lexical access tới bindings thuộc environment nơi function được tạo.
- Stale Closure xuất hiện khi bindings/data đó thuộc một older logical state so với current system state.
- Shared binding có thể được update và callback đọc current value của cùng binding.
- Parameter/local binding của old invocation không tự đổi khi system tạo invocation/state mới.
- Vì vậy phải trace binding identity trước khi gọi một callback "stale".
14. Teach Back (Dạy lại)
Giải thích cho một đồng nghiệp junior trong 2 phút:
"Stale Closure là gì? Tại sao Closure vẫn đúng về lexical scope nhưng callback vẫn có thể dùng state cũ?"
Yêu cầu:
- Dùng đúng terminology: binding, lexical environment, invocation, current state, stale.
- Không nói Closure luôn copy value.
- Phân biệt shared binding và old invocation binding.
- Đưa một ví dụ callback cũ + callback mới.
Mô phỏng
- Bạn nói:
- Closure luôn resolve variables theo lexical environment nơi function được tạo.
- Một stale closure xuất hiện khi callback vẫn dùng bindings/data của một older invocation hoặc derived snapshot, trong khi system hiện đã chuyển sang state mới.
- Ví dụ
createLogger(0)tạo callback giữ parametercount = 0. Sau đó global state thành5và ta tạo một invocation mới. Callback cũ không tự chuyển sang binding của invocation mới. - Vì vậy callback cũ có thể log
0trong khi callback mới log5. - Nhưng nếu callback capture trực tiếp một shared global binding
count, và binding đó được update thành5, callback có thể đọc5. Điều đó chứng minh Closure không đơn giản là "snapshot value". - Staleness phải được đánh giá so với state source mà system coi là current.
💡 Hình dung mỗi invocation là một tấm bản đồ tại một thời điểm. Callback cũ vẫn cầm đúng tấm bản đồ cũ; vấn đề là thành phố đã thay đổi.
Gợi ý đánh giá bản thân
- Bạn có chỉ ra callback đang giữ binding nào không?
- Bạn có phân biệt object mutation với object replacement không?
- Bạn có giải thích được vì sao callback cũ không tự dùng environment mới không?
- Bạn có nhận ra khi old state là intentional snapshot thay vì bug không?
15. Assessment (Đánh giá)
| Objective (Mục tiêu) | Hình thức đánh giá |
|---|---|
| Giải thích stale closure | Teach Back — old environment vs current state |
| Phân biệt shared/live vs old invocation | Prediction 1–3 |
| Trace derived snapshot | Worked Example |
| Debug stale configuration | Debug Lab — xác định đúng binding/object identity |
| Phân biệt mutation vs replacement | Edge Case 2 |
| Chọn snapshot vs latest semantics | Design Exercise |
| Chuẩn bị cho React stale closure | Production Scenario + Spiral |
16. Exit Criteria (Tiêu chí qua bài)
- [ ] Có thể giải thích Stale Closure mà không dùng rule "closure copy value".
- [ ] Có thể phân biệt old invocation binding với shared mutable binding.
- [ ] Dự đoán đúng 3/3 prediction scenarios.
- [ ] Có thể chỉ ra khi derived value trở thành snapshot cũ.
- [ ] Có thể giải thích object mutation khác object replacement thế nào với Closure.
- [ ] Debug được callback giữ config object cũ sau khi global config được replace.
- [ ] Có thể xác định requirement là snapshot hay latest state.
- [ ] Biết callback cũ không tự chuyển sang lexical environment của invocation mới.
- [ ] Không coi mọi async callback là stale closure.
- [ ] Sẵn sàng chuyển mental model này sang React stale closure ở Stage 8.
17. Spiral Connection (Liên kết xoắn ốc)
Previous (Trước): Closure Formation → Lifetime → Private State → Factory Functions → Closure trong Loop → Callback → Async. Bạn đã biết callback giữ lexical environment nào và có thể chạy sau khi creator return.
Current (Hiện tại): Stale Closure. Bạn học rằng callback có thể resolve bindings hoàn toàn đúng nhưng data của environment đó không còn đại diện cho current system state.
Next (Tiếp theo):
- Lesson 1.4.9 — Closure & Memory (Closure giữ references và làm object tiếp tục reachable ở mức awareness)
- Stage 3 — Async Concurrency, race condition và cancellation
- Stage 8 — React Hooks, dependency management và stale closures
- Stage 11 — Memory retention / profiling
- Stage 14 — Debugging và engineering judgment