Gõ vào ô search khiến cả list nặng ngừng phản hồi vài trăm ms — vì React render đồng bộ, mọi update đều “gấp” như nhau, nên cái list đang tính chặn luôn ký tự bạn vừa gõ. React 18 concurrent rendering cho phép đánh dấu một số update là “không gấp”, để React giữ input mượt và render phần nặng ở nền, thậm chí ngắt render đang dở khi có update quan trọng hơn.
Bài này đi qua bốn công cụ của concurrent rendering — createRoot, startTransition/useTransition, useDeferredValue, và Suspense — kèm khi nào dùng cái nào và vì sao concurrency không phải là đa thread.
1. createRoot: cách bật concurrent features
Mọi tính năng concurrent chỉ bật khi bạn mount app bằng createRoot (từ react-dom/client) thay cho ReactDOM.render cũ. Đây không phải một API “tuỳ chọn” — nếu vẫn dùng render, startTransition và các API cùng nhóm vẫn chạy nhưng React quay lại chế độ cũ, và bạn không nhận được lợi ích ngắt/ưu tiên.
1
2
3
4
5
6
// React 18+ — createRoot bật concurrent features
import { createRoot } from "react-dom/client";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(<App />);1
2
3
4
// React 17 và cũ hơn — render đồng bộ, không có concurrent features
import { render } from "react-dom";
render(<App />, document.getElementById("root"));Giải thích:
createRoottạo một root có khả năng ngắt render giữa chừng.- Từ đây, các update bạn đánh dấu “không gấp” mới được React lên lịch riêng.
- Nếu dùng framework (Next.js, Remix…), phần này đã được lo sẵn — bạn chỉ cần biết vì sao concurrent features hoạt động.
2. Urgent và non-urgent: startTransition, useTransition
React 18 chia update làm hai loại. Urgent là mặc định: gõ phím, click, kéo — những thứ phải phản hồi ngay nếu không thấy giật. Non-urgent (transition) là những update bạn chủ động hạ ưu tiên: render một list dài, một biểu đồ, một trang kết quả. React cho update urgent được phép ngắt update non-urgent đang render dở.
2.1. startTransition cho một update rời
Dạng đơn giản nhất là gọi thẳng startTransition và bọc setState nặng vào trong. Update ngoài callback vẫn là urgent.
1
2
3
4
5
6
7
8
9
import { startTransition } from "react";
// Urgent: hiện ngay thứ vừa gõ
setInputValue(input);
// Non-urgent: lọc/hiển thị kết quả có thể chậm hơn một nhịp
startTransition(() => {
setSearchQuery(input);
});2.2. useTransition và cờ isPending
Trong component, useTransition cho thêm cờ isPending để biết transition có đang chạy hay không — hữu ích để hiện spinner nhẹ mà không chặn input. Hook trả về đúng hai phần tử: [isPending, startTransition].
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
30
31
import { useState, useTransition } from "react";
function SearchBox({ allItems }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState(allItems);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
// Urgent: input phải cập nhật ngay, gõ mới không giật
setQuery(value);
// Non-urgent: lọc danh sách nặng, để React hạ ưu tiên
startTransition(() => {
setResults(allItems.filter((item) => item.includes(value)));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <span>Đang lọc…</span>}
<ul>
{results.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</>
);
}Giải thích:
setQuerynằm ngoài callback nên là urgent: ô input luôn theo kịp phím.setResultsnằm trongstartTransitionnên là transition: React render list ở nền và sẵn sàng bỏ dở nếu bạn gõ tiếp.isPendingcho bạn hiện trạng thái “đang lọc” mà không đóng băng UI.
Lưu ý
Chỉ những setState gọi đồng bộ bên trong callback của startTransition mới được đánh dấu là transition. Nếu bạn setState sau một await bên trong callback, update đó đã nằm ngoài phạm vi transition và trở lại urgent.
3. useDeferredValue: hoãn một giá trị phái sinh
useTransition hợp khi bạn sở hữu lời gọi setState. Nhưng nhiều khi giá trị nặng lại đến từ props — bạn không sửa được chỗ set nó. Lúc đó dùng useDeferredValue: đưa vào một giá trị, nhận lại một phiên bản “trễ một nhịp” của nó.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState, useDeferredValue } from "react";
function SearchPage({ allItems }) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* Danh sách đọc giá trị đã hoãn, không phải query mới nhất */}
<SearchResults query={deferredQuery} items={allItems} />
</>
);
}Giải thích:
- Khi
queryđổi, React render lần đầu vớideferredQueryvẫn là giá trị cũ — nên input phản hồi ngay, list chưa phải tính lại. - Ngay sau đó React lên lịch một lần render ở nền với giá trị mới. Lần render nền này có thể bị ngắt: gõ tiếp thì React vứt bản đang tính và làm lại từ đầu với giá trị mới nhất.
- Kết quả:
SearchResultschỉ thực sự re-render với giá trị mới sau khi bạn ngừng gõ một nhịp, còn input thì không bao giờ bị giật.
3.1. Chọn useTransition hay useDeferredValue
Hai hook giải quyết cùng một vấn đề — hạ ưu tiên phần render tốn kém — nhưng ở hai vị trí khác nhau.
useTransition | useDeferredValue | |
|---|---|---|
| Bạn điều khiển gì | update nào là transition (bọc setState) | một giá trị phái sinh |
| Có phải sửa nơi set state | Có — bọc trong startTransition | Không — chỉ hoãn value |
Cho isPending | Có | Không trực tiếp |
| Hợp khi | bạn sở hữu code gọi setState | value đến từ props/nơi bạn không sửa được |
Không cần dùng cả hai cho một giá trị: nếu update đã nằm trong transition thì useDeferredValue trên chính giá trị đó không sinh thêm lần render nền nào.
4. Suspense: khai báo trạng thái loading
Suspense cho phép bạn khai báo fallback cho một nhánh cây component chưa sẵn sàng, thay vì tự quản cờ isLoading rải rác. Khi một component bên trong “treo” (suspend), React hiển thị fallback cho tới khi nó sẵn sàng.
4.1. lazy import cho code splitting
Use case lâu đời nhất của Suspense là tách bundle với React.lazy: component chỉ được tải khi thực sự cần render.
1
2
3
4
5
6
7
8
9
10
11
12
import { lazy, Suspense } from "react";
// Dashboard chỉ tải khi lần đầu được render
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
);
}4.2. Data fetching với thư viện hỗ trợ Suspense
React 18 mở rộng Suspense cho data fetching, nhưng không phải cứ fetch trong useEffect là dùng được. Component phải “treo” đúng cách — điều mà các thư viện tích hợp Suspense (Relay, React Query ở chế độ suspense, hay data layer của Next.js) lo giúp. Khi kết hợp với transition, Suspense còn thông minh hơn: nếu một component suspend trong một transition, React sẽ không thay nội dung đang hiển thị bằng fallback, mà giữ nội dung cũ đến khi dữ liệu về, tránh cảnh nhấp nháy spinner.
4.3. Suspense trên server: streaming SSR
Ở phía server, React 18 thay renderToNodeStream (đã deprecated) bằng renderToPipeableStream. Điểm mạnh: server có thể gửi HTML của phần “vỏ” ngay, rồi stream nốt các nhánh còn đang chờ dữ liệu khi chúng sẵn sàng — mỗi Suspense boundary là một đơn vị stream độc lập.
1
2
3
4
5
6
7
8
9
10
import { renderToPipeableStream } from "react-dom/server";
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/main.js"],
onShellReady() {
// Phần vỏ đã render xong — gửi ngay, phần chờ stream sau
response.setHeader("content-type", "text/html");
pipe(response);
},
});5. Interruptible rendering: concurrency không phải đa thread
Điểm cốt lõi khiến transition mượt là render có thể bị ngắt. Với root cũ, một khi React bắt đầu render là chạy một mạch tới xong, chặn main thread. Với concurrent rendering, React render transition theo từng phần và kiểm tra xem có update urgent nào đang chờ không; nếu có, nó bỏ dở phần đang render, xử lý update gấp trước, rồi quay lại làm lại transition từ đầu với dữ liệu mới nhất.
View Mermaid diagram code
flowchart TD
Type["Gõ phím (urgent)"] --> Input[Cập nhật input ngay]
Type --> Start["startTransition: render list"]
Start --> BG[Render list ở nền]
BG --> More{"Có phím mới?"}
More -->|Có| Restart[Bỏ bản đang render, làm lại]
More -->|Không| Commit[Commit list lên màn hình]
Restart --> BG
style Input fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Commit fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Restart fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98a
style More fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbffĐây là chỗ dễ hiểu nhầm nhất: concurrent không phải là React render trên nhiều thread. JavaScript vẫn single-thread, mọi thứ vẫn chạy trên main thread. “Concurrent” ở đây là scheduling ưu tiên — React chia nhỏ công việc render và chọn làm việc gấp trước, việc không gấp sau, để main thread không bị một lần render dài chiếm dụng. Không có concurrent mode để bật; chỉ có các concurrent features bạn dùng dần khi cần.
6. Ranh giới với automatic batching
Cùng với createRoot, React 18 còn bật automatic batching làm mặc định: nhiều setState trong cùng một sự kiện (kể cả trong promise, setTimeout, hay native handler) được gộp thành một lần render. Đây là một chủ đề riêng — nó nói về gộp update để bớt render, khác với transition (hạ ưu tiên để giữ mượt) — nên bài này chỉ dừng ở việc nhắc: nếu bạn thắc mắc vì sao nhiều setState liên tiếp giờ chỉ re-render một lần, đó là automatic batching, và cách chủ động gom async task (debounce, flushSync) thuộc về một bài viết khác về batching.
Tương tự, chuyện một component re-render thừa dù state không đổi là bài toán của memo/useMemo — thuộc về một bài riêng về tối ưu re-render, không phải phần việc của concurrent rendering ở đây.
Tóm lại: createRoot là API bật concurrent features; startTransition/useTransition và useDeferredValue giúp bạn hạ ưu tiên phần render nặng để input luôn mượt; Suspense biến trạng thái loading thành thứ khai báo được, cả ở client lẫn khi streaming SSR. Tất cả dựa trên một ý tưởng: render có thể bị ngắt, và React tự chọn làm việc gấp trước. Nếu app của bạn có một ô search hay một tab nặng đang làm giật input, hãy thử bọc update nặng vào startTransition — thường chỉ vài dòng là thấy khác.