Home / Frontend / Batch Async Tasks Trong React: Từ setState Đến Debounce
Frontend

Batch Async Tasks Trong React: Từ setState Đến Debounce

Automatic batching React 18 và cách chủ động gộp async task bằng debounce, Promise.all, useTransition để giảm re-render.

Nhiều setState liên tiếp, hay một loạt async task đổ về cùng lúc, rất dễ khiến component re-render thừa và UI giật. Khi ngồi tối ưu, dev hay lẫn hai chuyện khác hẳn nhau: React tự gộp nhiều setState thành một lần render (automatic batching), còn bạn thì chủ động gộp nhiều async task — debounce một ô search, gom nhiều request thành một, hạ ưu tiên phần render nặng.

Bài này tách bạch hai loại “gộp” đó: cái nào React lo sẵn, cái nào bạn phải tự làm, và mỗi trường hợp dùng công cụ gì cho đúng.

1. Hai kiểu “gộp”: React tự lo và bạn chủ động lo

Trước khi đụng tới code, cần phân biệt hai khái niệm dễ nhầm vì cùng được gọi là “batch”:

  • Automatic batching — React gộp nhiều lần setState xảy ra gần nhau thành một lần re-render. Bạn không bật gì cả; React 18 làm mặc định.
  • Batch async task chủ động — bạn gộp nhiều thao tác async: chặn bớt số lần gọi hàm (debounce/throttle), gom nhiều request thành một, hoặc hạ ưu tiên update tốn kém. React không tự làm giúp — đây là việc của bạn.
Ai làmGộp cái gìCông cụ
Automatic batchingReactnhiều setState → 1 rendercreateRoot (mặc định), flushSync để opt-out
Batch chủ độngBạnnhiều async task → ít thao tácdebounce, Promise.all, useTransition

Phần A (mục 2) nói về cái React lo; phần B (mục 3–5) nói về cái bạn lo.

2. Automatic batching: nhiều setState, một lần render

Nếu mỗi setState gây một lần render riêng, một handler set ba state sẽ render ba lần với hai state trung gian vô nghĩa. Automatic batching gộp chúng lại: React chờ chạy hết đoạn code rồi mới render một lần với state cuối.

2.1. React 17 chỉ gộp trong event handler

Trong một React event handler, cả React 17 lẫn 18 đều gộp các setState thành một render. Dùng một ref đếm số lần render để thấy rõ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { useState, useRef } from "react";

function Counter() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);
  const renders = useRef(0);
  renders.current += 1; // tăng mỗi lần component render

  function handleClick() {
    setCount((c) => c + 1);
    setFlag((f) => !f);
    // Hai setState trong cùng một event handler:
    // React 17 lẫn 18 đều gộp → chỉ render 1 lần
  }

  return (
    <button onClick={handleClick}>
      count: {count} · renders: {renders.current}
    </button>
  );
}

Giải thích:

  • Mỗi lần click, renders.current chỉ nhích lên một nấc chứ không phải hai.
  • Vấn đề của React 17 nằm ở chỗ khác: khi hai setState đó không nằm trực tiếp trong event handler.

2.2. React 18 gộp cả trong promise và setTimeout

Đặt đúng hai setState ấy vào trong setTimeout (hoặc .then của một promise, hoặc một native event listener), React 17 sẽ render hai lần — vì nó chỉ gộp bên trong event handler của chính nó. React 18, khi app mount bằng createRoot, gộp cả những update này:

1
2
3
4
5
6
7
8
function handleClick() {
  setTimeout(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
    // React 17: render 2 lần (mỗi setState một lần)
    // React 18 + createRoot: render 1 lần (automatic batching)
  }, 0);
}

Giải thích:

  • createRoot (từ react-dom/client) là thứ bật automatic batching mở rộng này; nếu app còn mount bằng ReactDOM.render cũ thì vẫn theo hành vi React 17.
  • Vì sao thời điểm setTimeout/promise lại đáng nói? Vì mỗi cái rơi vào một task (macrotask) hoặc microtask khác nhau trong event loop. Nếu chưa chắc hai loại đó khác nhau ra sao, hai bài Tại Sao Promise Chạy Trước setTimeout(0)Asynchronous JavaScript Và Macro Task Queue giải thích kỹ phần nền này.

2.3. flushSync: opt-out khi cần đọc DOM ngay

Batching tốt cho hiệu năng, nhưng đôi khi bạn cần DOM cập nhật ngay lập tức — ví dụ set state xong phải đo kích thước phần tử để scroll tới nó. flushSync (từ react-dom) buộc React flush update và cập nhật DOM đồng bộ trước khi chạy tiếp:

1
2
3
4
5
6
7
8
9
10
11
import { flushSync } from "react-dom";

function handleClick() {
  flushSync(() => {
    setCount((c) => c + 1);
  });
  // Tới đây DOM đã cập nhật count — đo/đọc layout được ngay
  flushSync(() => {
    setFlag((f) => !f);
  });
}

Lưu ý

  • Mỗi flushSync ép một lần render + commit DOM riêng, nên đoạn trên render hai lần — đúng thứ automatic batching muốn tránh.
  • Chỉ dùng khi thật sự cần đọc DOM giữa hai update. Lạm dụng flushSync là tự tay bỏ đi lợi ích của batching.

3. Debounce: gộp nhiều lần gõ thành một lần xử lý

Automatic batching lo phần “nhiều setState → một render”, nhưng nó không giúp bạn tránh việc gọi hàm quá nhiều lần. Kinh điển là ô search: mỗi phím gõ là một lần gọi API. debounce gộp một chuỗi lời gọi dồn dập thành một lời gọi, sau khi người dùng ngừng gõ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const search = debounce((q) => console.log("Gọi API với:", q), 300);

// Người dùng gõ liên tục: r → re → rea → react
search("r");
search("re");
search("rea");
search("react");
// Chỉ lần gõ cuối mới thực sự gọi API, sau 300ms không gõ thêm

Bấm Run: console chỉ in đúng một dòng — Gọi API với: react. Ba lời gọi đầu bị clearTimeout huỷ trước khi kịp chạy, nên bốn lần “gõ” gộp lại thành một lần gọi API.

Trong React, tạo hàm debounce một lần bằng useMemo rồi tách state input khỏi lời gọi bị hoãn:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useMemo, useState } from "react";

function SearchBox({ onSearch }) {
  const [text, setText] = useState("");
  // Không tạo lại hàm debounce mỗi lần render (onSearch cần ổn định)
  const debouncedSearch = useMemo(() => debounce(onSearch, 300), [onSearch]);

  function handleChange(e) {
    setText(e.target.value); // input cập nhật ngay, không giật
    debouncedSearch(e.target.value); // lời gọi API thì hoãn lại
  }

  return <input value={text} onChange={handleChange} />;
}

Giải thích:

  • text update mỗi phím để ô input mượt; chỉ lời gọi API mới bị debounce.
  • useMemo tránh tạo lại hàm debounce ở mỗi render — nếu render nào cũng tạo mới, biến timer bên trong reset liên tục và debounce mất tác dụng. Đây là tối ưu chứ không phải cam kết (React có thể bỏ giá trị memo), nên onSearch cần ổn định — thường bọc useCallback ở component cha vì nó nằm trong dependency của useMemo.
  • Throttle là kỹ thuật gần giống: thay vì “chờ ngừng mới chạy”, throttle “chạy tối đa một lần mỗi khoảng” — hợp cho scroll/resize.

4. Gộp nhiều request thành ít lần gọi

Debounce cắt bớt số lời gọi theo thời gian. Còn khi nhiều request cùng cần ngay bây giờ, cách gộp lại khác.

4.1. Promise.all: chạy song song thay vì tuần tự

Khi cần nhiều dữ liệu độc lập, await từng cái một là cái bẫy hiệu năng phổ biến nhất: mỗi await chặn cái sau, thời gian cộng dồn. Nếu các request không phụ thuộc nhau, gom vào Promise.all để chạy song song:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function fetchUser(id, ms) {
  return new Promise((resolve) =>
    setTimeout(() => resolve(`user-${id}`), ms)
  );
}

const round100 = (ms) => Math.round(ms / 100) * 100;

// Tuần tự: mỗi await chờ cái trước xong → thời gian cộng dồn
async function sequential() {
  const start = Date.now();
  const a = await fetchUser(1, 100);
  const b = await fetchUser(2, 100);
  const c = await fetchUser(3, 100);
  console.log("Tuần tự:", [a, b, c], `~${round100(Date.now() - start)}ms`);
}

// Song song: gọi cả 3 cùng lúc, chờ cái lâu nhất
async function parallel() {
  const start = Date.now();
  const [a, b, c] = await Promise.all([
    fetchUser(1, 100),
    fetchUser(2, 100),
    fetchUser(3, 100),
  ]);
  console.log("Song song:", [a, b, c], `~${round100(Date.now() - start)}ms`);
}

sequential().then(parallel);

Bấm Run, console in ra:

1
2
Tuần tự: [ "user-1", "user-2", "user-3" ] ~300ms
Song song: [ "user-1", "user-2", "user-3" ] ~100ms

Ba request tuần tự mất ~300ms (100 + 100 + 100); gộp vào Promise.all chỉ mất ~100ms vì chúng chạy chồng lên nhau. Lưu ý về lỗi: chỉ cần một request trong Promise.all reject là cả Promise.all reject và các kết quả khác bị bỏ — nếu muốn chờ hết rồi mới xử lý từng cái thành công/thất bại riêng, dùng Promise.allSettled.

4.2. Gom request trong một tick (dataloader-style)

Promise.all hợp khi bạn biết trước danh sách cần gọi. Nhưng nhiều khi các lời gọi đến từ những chỗ rời rạc trong cây component, mỗi nơi cần riêng một id. Pattern dataloader gom mọi lời gọi trong cùng một tick rồi gửi một request duy nhất:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function createBatcher(batchFn) {
  let queue = [];
  let scheduled = false;
  return function load(id) {
    return new Promise((resolve) => {
      queue.push({ id, resolve });
      if (scheduled) return;
      scheduled = true;
      // Gom mọi lời gọi trong cùng một tick, gửi một request duy nhất
      queueMicrotask(async () => {
        const batch = queue;
        queue = [];
        scheduled = false;
        console.log("Gửi 1 request cho ids:", batch.map((item) => item.id));
        const results = await batchFn(batch.map((item) => item.id));
        batch.forEach((item, i) => item.resolve(results[i]));
      });
    });
  };
}

const loadUser = createBatcher(async (ids) => ids.map((id) => `User ${id}`));

// 3 nơi khác nhau cùng cần user trong một tick → gộp thành 1 request
Promise.all([loadUser(1), loadUser(2), loadUser(3)]).then((users) =>
  console.log("Nhận về:", users)
);

Bấm Run, console in ra:

1
2
Gửi 1 request cho ids: [ 1, 2, 3 ]
Nhận về: [ "User 1", "User 2", "User 3" ]

queueMicrotask hoãn phần gom lại tới cuối tick hiện tại, sau khi cả ba loadUser đã kịp đẩy id vào queue — nên chỉ một request được gửi cho [1, 2, 3]. Đây chính là lý do phải nắm microtask: nếu chưa rõ vì sao callback này chạy sau ba lời gọi nhưng trước mọi setTimeout, xem lại Tại Sao Promise Chạy Trước setTimeout(0).

5. Hạ ưu tiên update nặng: useTransition và useDeferredValue

Kiểu batch chủ động thứ ba không phải “gộp ít lời gọi hơn” mà là “đừng để update nặng chặn update gấp”. React 18 cho useTransitionuseDeferredValue để đánh dấu một update là “không gấp”, nhường ưu tiên cho input:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, useTransition } from "react";

function Search({ allItems }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value); // urgent: ô input theo kịp phím
    startTransition(() => {
      setResults(allItems.filter((item) => item.includes(value)));
    }); // non-urgent: lọc list nặng, để React hạ ưu tiên
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>Đang lọc…</span>}
    </>
  );
}

Ở đây không hề có “ít request hơn”: số lần lọc vẫn thế, nhưng React được phép ngắt việc lọc để giữ ô input mượt. Cơ chế ngắt/ưu tiên (interruptible rendering), useDeferredValue, và Suspense thuộc về concurrent rendering — đã đi sâu ở bài Concurrency Trong React 18: Transitions Và Suspense, nên ở đây chỉ nhắc để bạn thấy nó cũng là một cách điều tiết async work.

6. Anti-pattern: setState trong vòng lặp async và await tuần tự

Hai lỗi hay gặp khi trộn async với state:

1. setState trong vòng lặp async không kiểm soát → render storm. Automatic batching của React 18 chỉ gộp các setState chạy đồng bộ trong cùng một tick. Sau mỗi await, bạn đã sang một tick khác, nên setState ở đó không được gộp với update trước — n item thành n lần render:

1
2
3
4
5
6
7
// ❌ Mỗi item một setState → render lại cả list n lần
async function loadAll(ids) {
  for (const id of ids) {
    const item = await fetchItem(id);
    setItems((prev) => [...prev, item]);
  }
}

Gom kết quả rồi setState một lần — vừa chạy song song, vừa chỉ một render:

1
2
3
4
5
// ✅ Chạy song song, gộp một lần setState
async function loadAll(ids) {
  const items = await Promise.all(ids.map(fetchItem));
  setItems(items);
}

2. await tuần tự khi các việc độc lập — đúng cái bẫy ở mục 4.1: cộng dồn thời gian một cách vô ích. Quy tắc gọn: phụ thuộc nhau thì await nối tiếp, độc lập thì Promise.all. Còn chuyện một component re-render thừa dù state không đổi lại là bài toán của memo/useMemo — một chủ đề riêng về tối ưu re-render, không phải phần việc của batching ở đây.

Tóm lại, có hai tầng “gộp” khác nhau và đừng lẫn. Automatic batching là việc React tự lo — nhiều setState gần nhau thành một render, mặc định từ React 18 với createRoot, và bạn chỉ can thiệp bằng flushSync khi cần đọc DOM ngay. Batch chủ động là việc của bạn — debounce để bớt số lần gọi, Promise.all và dataloader để gộp request, useTransition để hạ ưu tiên phần nặng. Khi UI giật, hãy hỏi trước: đây là vấn đề nhiều render (React lo) hay nhiều async task (bạn lo)? Trả lời được câu đó là chọn đúng công cụ.

← Previous
Tính Năng Mới Trong React 19: Actions Và Hook use()
Table of Contents
  1. 1. Hai kiểu “gộp”: React tự lo và bạn chủ động lo
  2. 2. Automatic batching: nhiều setState, một lần render
    1. 2.1. React 17 chỉ gộp trong event handler
    2. 2.2. React 18 gộp cả trong promise và setTimeout
    3. 2.3. flushSync: opt-out khi cần đọc DOM ngay
  3. 3. Debounce: gộp nhiều lần gõ thành một lần xử lý
  4. 4. Gộp nhiều request thành ít lần gọi
    1. 4.1. Promise.all: chạy song song thay vì tuần tự
    2. 4.2. Gom request trong một tick (dataloader-style)
  5. 5. Hạ ưu tiên update nặng: useTransition và useDeferredValue
  6. 6. Anti-pattern: setState trong vòng lặp async và await tuần tự