Closure trong JavaScript và React Batching: Vì sao state cập nhật không như bạn nghĩ?
Ngày 30 tháng 3, 2026
Nhiều bug liên quan đến setState không nằm ở React API, mà nằm ở cách closure "giữ ảnh chụp" giá trị state tại thời điểm function được tạo.
Khi thêm batching của React vào, kết quả càng dễ gây nhầm lẫn nếu mình suy nghĩ theo kiểu "setState là cập nhật ngay lập tức".
1. Closure là gì (nhìn theo góc debug)
Closure là khi function nhớ được biến ở scope bên ngoài nó.
function createCounter() {
let value = 0;
return () => {
value += 1;
return value;
};
}
const next = createCounter();
console.log(next()); // 1
console.log(next()); // 2Điểm quan trọng trong React: mỗi lần render là một "snapshot" dữ liệu mới.
Điều đó có nghĩa:
- Render A tạo ra
handleClickAvà closure của nó giữcounttại thời điểm A. - Render B tạo ra
handleClickBkhác, giữcountcủa thời điểm B. - Nếu callback chạy muộn (timeout, promise), nó vẫn dùng snapshot của lần render đã tạo ra callback đó.
Vì vậy, closure không phải bug. Closure chỉ là cơ chế ngôn ngữ. Bug xuất hiện khi ta kỳ vọng callback luôn đọc state mới nhất, trong khi thực tế callback đang đọc state đã capture trước đó.
Một cách nghĩ dễ debug:
rendertạo ra "ảnh chụp" biến.handler/callbackchỉ thấy ảnh chụp nó cầm.- Callback chạy càng trễ, khả năng lệch với state hiện tại càng cao.
CLOSURE SNAPSHOT PER RENDER
Render #1
count = 0
handler: handleClick@R1
Render #2
count = 1
handler: handleClick@R2
Render #3
count = 2
handler: handleClick@R3
const [count, setCount] = useState( 0);
const handleClick@R1 = () => {
// uses captured count from this render
setCount(count + 1);
};
A new handler is created and captures count = 0.
2. React batching ảnh hưởng thế nào?
React thường gom nhiều setState vào một đợt xử lý (batch) để giảm số lần render.
Về bản chất, setState không mutate state ngay tại chỗ. Nó đưa một update vào queue, sau đó React flush queue để tính state mới.
Có 2 kiểu update cần tách bạch:
2.1 Direct value update
setCount(count + 1)React nhận "giá trị đã tính sẵn" tại thời điểm closure hiện tại.
Nếu trong cùng batch bạn gọi nhiều lần direct value, các lời gọi đó thường dựa trên cùng một count đã capture. Vì vậy update sau có thể đè update trước theo kiểu "last write wins".
2.2 Functional updater
setCount((prev) => prev + 1)React nhận "hàm chuyển trạng thái". Khi flush queue, React chạy lần lượt từng updater với prev mới nhất tại thời điểm đó.
Đây là lý do functional updater ổn định hơn khi có nhiều update liên tiếp hoặc callback bất đồng bộ.
2.3 Flush là gì và React flush queue ra sao?
"Flush" là lúc React lấy các update đã enqueue ra để tính state mới và commit render.
Bạn có thể hình dung theo pipeline:
- Collect phase: Event/callback chạy, nhiều
setStateđược đưa vào queue. - Flush phase: React duyệt queue theo thứ tự enqueue (FIFO logic).
- Commit phase: React có state cuối cùng, rồi render/commit UI.
Điểm quan trọng:
- Flush không đồng nghĩa "mỗi lệnh setState render ngay".
- Trong một batch, React thường đợi callback/event kết thúc rồi mới flush.
- Mỗi item trong queue được áp dụng lên state tạm thời hiện tại, rồi truyền cho item kế tiếp.
Ví dụ một queue pha trộn direct value và updater:
setCount(5); // direct value
setCount((prev) => prev+1); // updater
setCount(7); // direct valueNếu state nền ban đầu là 0, thứ tự flush sẽ là:
- áp dụng
5-> state tạm là5 - áp dụng updater -> state tạm là
6 - áp dụng
7-> state tạm là7
State cuối cùng là 7.
Nên câu "batching quyết định thời điểm và thứ tự flush" nghĩa là:
- React quyết định khi nào bắt đầu xử lý queue.
- Queue quyết định thứ tự update nào chạy trước/sau.
- Hai yếu tố này ảnh hưởng trực tiếp kết quả cuối.
2.4 Closure tác động vào batching như thế nào?
Sự kết hợp gây lỗi phổ biến là:
- Callback giữ closure cũ.
- Callback tạo direct value update.
- Các update bị batch lại và xử lý sau.
- Kết quả cuối khác với trực giác "cộng trừ tuần tự".
Nói ngắn gọn:
- Closure quyết định đầu vào của update.
- Batching quyết định thời điểm và thứ tự flush.
- Cả hai kết hợp sẽ quyết định state cuối.
Nếu đầu vào đã stale (do closure cũ), batching không thể "tự sửa" được. Vì vậy pattern an toàn là chuyển từ direct value sang updater function khi state mới phụ thuộc state cũ.
3. Ví dụ 1: click 1 lần với 2 lệnh cập nhật trái chiều
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount(count - 1);
setCount(count + 1);
};Sau 1 lần click, count bằng bao nhiêu?
Kết quả cuối cùng: 1.
Giải thích:
- Tại lần render hiện tại, closure giữ
count = 0. setCount(count - 1)tính ra-1.setCount(count + 1)tính ra1.- React batched 2 update trong cùng event, update sau cùng thắng update trước.
- Vì vậy state cuối là
1.
Nếu bạn kỳ vọng là 0 (vì nghĩ -1 rồi +1), thì đây là chỗ closure + batching dễ gây nhầm lẫn.
REACT BATCHING: SINGLE CLICK CASE
const [count, setCount] = useState(0);const handleClickButton = () => {setCount(count - 1);setCount(count + 1);};
Step 1: Handler runs with closure count = 0.
(flushed)
count = 0
Expected after click: count = 1
Trải nghiệm trực tiếp với full component
INTERACTIVE DEMO: SINGLE CLICK BATCHING
Full component code
function SingleClickDemo() {
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount(count - 1);
setCount(count + 1);
};
return (
<div>
<p>count: {count}</p>
<button onClick={handleClickButton}>Click 1 lần</button>
</div>
);
}Live playground
count cuối cùng sau 1 click: 1
Run logs
- (chua co log)
4. Ví dụ 2: click liên tục 3 lần trong 3s với setTimeout
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount(count - 1);
setTimeout(() => {
setCount(count + 1);
}, 3000);
};Giả sử bạn click 3 lần rất nhanh trong vòng 3 giây.
Diễn biến theo thời gian
Thời điểm click:
- Click 1: closure thấy
count = 0-> đặt-1 - Click 2: closure mới thấy
count = -1-> đặt-2 - Click 3: closure mới thấy
count = -2-> đặt-3
Sau 3 giây, 3 timeout lần lượt nổ:
- Timeout từ click 1 dùng closure
count = 0-> set1 - Timeout từ click 2 dùng closure
count = -1-> set0 - Timeout từ click 3 dùng closure
count = -2-> set-1
Kết quả cuối cùng sau khi tất cả timeout chạy xong: -1.
Số này thường không phải điều người viết mong đợi, vì update trong timeout đang dùng state cũ đã capture.
3 CLICKS + 3S TIMEOUT: DIRECT VS UPDATER
Direct value: Click 1 immediate: 0 -> -1
Functional updater: Click 1 immediate: 0 -> -1
direct count: -1
updater count: -1
const handleClickButton = () => {setCount(count - 1);setTimeout(() => setCount(count + 1), 3000);};
Final after all timeouts: count = -1
const handleClickButton = () => {setCount((prev) => prev - 1);setTimeout(() => setCount((prev) => prev + 1), 3000);};
Final after all timeouts: count = 0
Trải nghiệm trực tiếp với full component
INTERACTIVE DEMO: 3 CLICKS + 3S TIMEOUT
Full component code (stale closure)
function StaleTimeoutDemo() {
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount(count - 1);
setTimeout(() => {
setCount(count + 1);
}, 3000);
};
return <button onClick={handleClickButton}>Click</button>;
}Full component code (functional updater)
function UpdaterTimeoutDemo() {
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount((prev) => prev - 1);
setTimeout(() => {
setCount((prev) => prev + 1);
}, 3000);
};
return <button onClick={handleClickButton}>Click</button>;
}Live playground
Stale closure path
count: 0
Updater path
count: 0
Kỳ vọng khi simulate 3 click: stale thường về -1, updater về 0.
Run logs
- (chua co log)
5. Pattern chuẩn: functional updater
Dùng callback updater để React cập nhật dựa trên state mới nhất tại thời điểm xử lý queue:
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount((prev) => prev - 1);
setTimeout(() => {
setCount((prev) => prev + 1);
}, 3000);
};Với cách này:
- Mỗi update không phụ thuộc vào
countcủa closure cũ. - React luôn dựa vào giá trị state mới nhất (
prev) để tính. - Hạn chế stale state trong event handler và async callback.
Callback của setState thực sự chạy như thế nào?
Khi bạn viết setCount((prev) => prev + 1), callback này không chạy ngay tại dòng code đó. React chỉ enqueue callback vào update queue.
Đến lúc React flush queue, callback mới được gọi với prev hiện tại của từng bước:
- Lấy state nền ban đầu của queue.
- Chạy updater #1 với
prevhiện tại -> tạo state mới. - Chạy updater #2 với state mới vừa tạo -> tiếp tục tạo state mới.
- Lặp lại cho đến hết queue rồi commit render.
Ví dụ:
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);Nếu state ban đầu là 0, React flush theo chuỗi:
- updater 1:
prev=0->1 - updater 2:
prev=1->2 - updater 3:
prev=2->3
Kết quả cuối là 3.
So sánh với direct value:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);Nếu closure đang giữ count=0, cả 3 lệnh đều enqueue cùng giá trị 1, nên state cuối thường chỉ là 1.
Tóm lại:
- Callback updater là "state transition function", không phải "giá trị đã chốt".
prevlà state mới nhất tại thời điểm React chạy queue, không phải biếncountở closure cũ.- Vì thế callback updater là lựa chọn mặc định khi state mới phụ thuộc state cũ.
6. So sánh nhanh: direct value vs updater function
Direct value (dễ stale)
setCount(count + 1);
setCount(count + 1);Với count = 0, kết quả thường là 1 (không phải 2).
Functional updater (an toàn)
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);Kết quả là 2 vì từng update dựa trên state mới nhất trong queue.
7. Checklist tránh bug closure trong React
- Nếu state mới phụ thuộc state cũ, ưu tiên functional updater.
- Cẩn thận với
setTimeout,Promise.then, event listener vì chúng dễ xài closure cũ. - Khi debug, hãy tự hỏi: "Function này được tạo ở render nào? Nó đang giữ state nào?"
- Tách logic cập nhật thành function rõ ràng để test dễ hơn.
Tổng kết
Closure không xấu, batching cũng không xấu. Vấn đề là mình đang mong chạy theo cơ chế đồng bộ trong khi runtime đang là bất đồng bộ + queue.
Nếu nhớ 1 quy tắc duy nhất, hãy nhớ quy tắc này:
State mới phụ thuộc state cũ => dùng setState((prev) => ...).