Closure trong JavaScript và React Batching: Vì sao state cập nhật không như bạn nghĩ?

Hoang Vu,7 min read

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:

  1. Render A tạo ra handleClickA và closure của nó giữ count tại thời điểm A.
  2. Render B tạo ra handleClickB khác, giữ count của thời điểm B.
  3. 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:

  1. render tạo ra "ảnh chụp" biến.
  2. handler/callback chỉ thấy ảnh chụp nó cầm.
  3. 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

Step 1/3

Render #1

count = 0

handler: handleClick@R1

Render #2

count = 1

handler: handleClick@R2

Render #3

count = 2

handler: handleClick@R3

Active closure snapshot

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.

React re-renderNew click uses latest handler

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:

  1. Collect phase: Event/callback chạy, nhiều setState được đưa vào queue.
  2. Flush phase: React duyệt queue theo thứ tự enqueue (FIFO logic).
  3. Commit phase: React có state cuối cùng, rồi render/commit UI.

Điểm quan trọng:

  1. Flush không đồng nghĩa "mỗi lệnh setState render ngay".
  2. Trong một batch, React thường đợi callback/event kết thúc rồi mới flush.
  3. 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 value

Nếu state nền ban đầu là 0, thứ tự flush sẽ là:

  1. áp dụng 5 -> state tạm là 5
  2. áp dụng updater -> state tạm là 6
  3. á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à:

  1. React quyết định khi nào bắt đầu xử lý queue.
  2. Queue quyết định thứ tự update nào chạy trước/sau.
  3. 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à:

  1. Callback giữ closure cũ.
  2. Callback tạo direct value update.
  3. Các update bị batch lại và xử lý sau.
  4. 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:

  1. Closure quyết định đầu vào của update.
  2. Batching quyết định thời điểm và thứ tự flush.
  3. 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:

  1. Tại lần render hiện tại, closure giữ count = 0.
  2. setCount(count - 1) tính ra -1.
  3. setCount(count + 1) tính ra 1.
  4. React batched 2 update trong cùng event, update sau cùng thắng update trước.
  5. 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

Step 1/4
Code mapping
const [count, setCount] = useState(0);
const handleClickButton = () => {
setCount(count - 1);
setCount(count + 1);
};

Step 1: Handler runs with closure count = 0.

Update queue

(flushed)

State

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: 0

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:

  1. Click 1: closure thấy count = 0 -> đặt -1
  2. Click 2: closure mới thấy count = -1 -> đặt -2
  3. Click 3: closure mới thấy count = -2 -> đặt -3

Sau 3 giây, 3 timeout lần lượt nổ:

  1. Timeout từ click 1 dùng closure count = 0 -> set 1
  2. Timeout từ click 2 dùng closure count = -1 -> set 0
  3. 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

Step 1/6
Timeline step 1/6

Direct value: Click 1 immediate: 0 -> -1

Functional updater: Click 1 immediate: 0 -> -1

direct count: -1

updater count: -1

Stale closure path (direct value)
const handleClickButton = () => {
setCount(count - 1);
setTimeout(() => setCount(count + 1), 3000);
};

Final after all timeouts: count = -1

Correct path (functional updater)
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:

  1. Mỗi update không phụ thuộc vào count của closure cũ.
  2. React luôn dựa vào giá trị state mới nhất (prev) để tính.
  3. 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:

  1. Lấy state nền ban đầu của queue.
  2. Chạy updater #1 với prev hiện tại -> tạo state mới.
  3. Chạy updater #2 với state mới vừa tạo -> tiếp tục tạo state mới.
  4. 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:

  1. updater 1: prev=0 -> 1
  2. updater 2: prev=1 -> 2
  3. 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:

  1. Callback updater là "state transition function", không phải "giá trị đã chốt".
  2. prev là state mới nhất tại thời điểm React chạy queue, không phải biến count ở closure cũ.
  3. 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

  1. Nếu state mới phụ thuộc state cũ, ưu tiên functional updater.
  2. Cẩn thận với setTimeout, Promise.then, event listener vì chúng dễ xài closure cũ.
  3. Khi debug, hãy tự hỏi: "Function này được tạo ở render nào? Nó đang giữ state nào?"
  4. 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) => ...).

2026 © @hoag/blog.