Gõ vào ô input ở dòng thứ ba của một list, rồi thêm một dòng mới lên đầu — chữ bạn vừa gõ nhảy sang dòng khác. Hay một component mất sạch state chỉ vì bạn đổi thẻ bọc từ div sang section. Những bug “ma” kiểu này không phải lỗi ngẫu nhiên: chúng là hệ quả trực tiếp của thuật toán diffing và kiến trúc Fiber chạy bên dưới React.
Bài này mổ xẻ ba câu hỏi thực tế: React so sánh hai cây element thế nào, vì sao key quyết định state của bạn còn hay mất, và vì sao một lần render đang chạy dở lại ngắt được giữa chừng. Nắm được chúng, bạn đoán trước hành vi của React thay vì mỗi lần lại bất ngờ.
1. Khi setState chạy: element tree, render phase và commit phase
Khi bạn gọi setState, React không lập tức đụng vào DOM. Nó chạy hai bước tách biệt: dựng và so sánh một cây mô tả UI trong bộ nhớ, rồi mới ghi phần chênh lệch xuống màn hình. Hiểu được đâu là “bản mô tả” và đâu là “DOM thật” là chìa khóa cho mọi thứ còn lại.
1.1. Element: bản mô tả rẻ, immutable
JSX chỉ là đường cú pháp cho React.createElement. Kết quả của nó là một plain object mô tả UI — không phải DOM node, cũng không phải instance của component. Mỗi lần render, React dựng một cây các object như vậy, so sánh với cây của lần trước, rồi vứt cả cây cũ đi. Đây chính là thứ nhiều người quen gọi là “virtual DOM”; react.dev ngày nay mô tả thẳng hơn: UI của bạn là một cái cây (UI as a tree) gồm các element.
1
2
3
4
5
6
7
8
9
import React from "react";
const button = React.createElement("button", { className: "cta" }, "Mua ngay");
const listItem = React.createElement("li", { key: "todo-1" }, "Học Fiber");
console.log("type:", button.type);
console.log("key:", button.key);
console.log("props:", button.props);
console.log("key của item có key:", listItem.key);Giải thích:
- Một element chỉ gồm vài field:
type(thẻ host như"button", hoặc là function/class component),key, vàprops. - Bấm Run:
typelà"button",keylànull(vì không truyền),propslà{ className: "cta", children: "Mua ngay" }, cònlistItemcókeylà"todo-1". - Element rẻ đến mức vứt đi mỗi render cũng không sao — nó chỉ là mô tả. State thật không sống ở đây (mục 5 sẽ chỉ nó sống ở đâu).
1.2. Hai phase: render (ngắt được) và commit (đồng bộ)
setState lên lịch cho một lần render, và lần render đó chia làm hai phase:
- Render phase: React gọi các component, dựng cây element mới, rồi so sánh với cây trước — bước so sánh này gọi là reconciliation. Phase này thuần túy tính toán trong bộ nhớ, nên React được phép tạm dừng nó, bỏ dở, hoặc chạy lại.
- Commit phase: React ghi đúng phần chênh lệch xuống DOM, một lần, đồng bộ và không ngắt.
useLayoutEffectchạy đồng bộ ngay cuối commit (sau khi DOM đổi, trước khi trình duyệt vẽ), cònuseEffectchạy sau khi vẽ xong.
Đừng nhầm với batching: chuyện nhiều setState trong một handler gộp lại thành một lần render là việc của batching; còn ở đây ta đang nói về những gì xảy ra bên trong một lần render đã được lên lịch. Vì sao render phase ngắt được mà commit thì không — cấu trúc Fiber ở mục 5 và work loop ở mục 6 trả lời.
View Mermaid diagram code
flowchart LR
S["setState"] --> R["Render phase:<br/>dựng cây WIP"]
R --> Q{"Có update<br/>gấp hơn?"}
Q -->|Có| D["Vứt WIP,<br/>làm lại"]
D --> R
Q -->|Không| C["Commit:<br/>ghi DOM"]
C --> E["Effects chạy"]
style S fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Q fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style R fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff
style D fill:#2a2008,stroke:#ffc043,stroke-width:1.5px,color:#ffd98a
style C fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style E fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff502. Hai heuristic của thuật toán diffing O(n)
So khớp tối ưu hai cây bất kỳ là bài toán tốn O(n³) — quá đắt cho một UI phải cập nhật sau mỗi lần gõ phím. React đổi lấy tốc độ bằng hai heuristic, và nhờ chúng, diffing rút xuống còn O(n):
- Hai element khác
typesinh ra hai cây khác nhau — không cần so sâu, cứ thay cả cụm (chi tiết ở 2.1 và 2.2). keyđánh dấu identity — nó cho React biết một phần tử con trong list là “vẫn là nó” qua các lần render, dù đổi vị trí (chi tiết ở 2.3).
Đoạn dưới mô phỏng quyết định cốt lõi của thuật toán khi so hai element tại cùng một vị trí:
1
2
3
4
5
6
7
8
9
10
11
12
13
function diff(oldEl, newEl) {
if (oldEl.type !== newEl.type) {
return `type đổi ${oldEl.type}→${newEl.type}: UNMOUNT cây cũ + MOUNT cây mới (state mất)`;
}
return `type giống ${newEl.type}: UPDATE props tại chỗ (state giữ)`;
}
const before = { type: "div", props: { children: "Counter" } };
const afterSameType = { type: "div", props: { children: "Counter đổi màu" } };
const afterNewType = { type: "section", props: { children: "Counter" } };
console.log(diff(before, afterSameType));
console.log(diff(before, afterNewType));Giải thích: đây là bản rút gọn của việc React làm ở mỗi node. type giống thì tái sử dụng node cũ và chỉ ghi đè props; type khác thì bỏ cả nhánh và dựng lại. Bấm Run để thấy hai quyết định trái ngược nhau chỉ vì một chữ div đổi thành section.
2.1. Type khác → đập đi xây lại
Khi element ở một vị trí đổi type, React không cố “sửa” — nó unmount toàn bộ subtree cũ (hủy DOM, hủy state, chạy cleanup của mọi effect) rồi mount subtree mới từ đầu. Demo dưới có một Counter giữ state, bọc trong một thẻ mà bạn đổi từ div sang section. Bấm Run:
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<div id="root"></div>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.7",
"react-dom/client": "https://esm.sh/react-dom@19.2.7/client?deps=react@19.2.7"
}
}
</script>
<script type="module">
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
const h = React.createElement;
function Counter() {
const [count, setCount] = useState(0);
return h("button", { id: "counter", onClick: () => setCount((c) => c + 1) }, "count=" + count);
}
function App() {
const [tag, setTag] = useState("div");
return h("div", null,
h("button", { id: "toggle", onClick: () => setTag((t) => (t === "div" ? "section" : "div")) }, "đổi thẻ bọc"),
h(tag, { id: "wrapper" }, h(Counter))
);
}
// createRoot commit bất đồng bộ: chờ tới khi DOM phản ánh thay đổi rồi mới đọc.
const until = (fn) => new Promise((resolve, reject) => {
let n = 0;
(function poll() {
let ok = false;
try { ok = fn(); } catch (e) {}
if (ok) return resolve();
if (++n > 300) return reject(new Error("timeout"));
setTimeout(poll, 16);
})();
});
const counterText = () => (document.getElementById("counter") || {}).textContent;
const wrapperTag = () => (document.getElementById("wrapper") || {}).tagName;
createRoot(document.getElementById("root")).render(h(App));
async function run() {
await until(() => counterText() === "count=0"); // đợi render đầu
const counter = document.getElementById("counter");
counter.click(); counter.click(); counter.click();
await until(() => counterText() === "count=3");
console.log("thẻ bọc:", wrapperTag().toLowerCase(), "·", counterText(), "(đã bấm +1 ba lần)");
document.getElementById("toggle").click();
await until(() => wrapperTag() === "SECTION");
console.log("thẻ bọc:", wrapperTag().toLowerCase(), "·", counterText(), "→ Counter remount, state mất");
}
run();
</script>Giải thích: trước khi đổi thẻ, count là 3; sau khi thẻ bọc đổi từ div sang section, count về 0. Vị trí của Counter trong cây không đổi, nhưng cha của nó đổi type, nên cả nhánh bị đập đi xây lại — kèm theo đó là cleanup của mọi effect trong subtree (thời điểm cleanup chạy khi unmount xem ở bài lifecycle). Đây là lý do một thay đổi tưởng như vô hại lại xóa sạch state.
2.2. Type giống → update props tại chỗ
Mặt còn lại của heuristic: khi type giữ nguyên, React tái sử dụng đúng DOM node và đúng fiber cũ, chỉ ghi những attribute thực sự đổi. Component thì giữ nguyên memoizedState — tức toàn bộ state và hooks của nó. Bạn vừa thấy điều này trong hai demo trên: chừng nào thẻ bọc còn là div, Counter bên trong vẫn giữ số đếm.
2.3. List: vì sao React đòi key
Với các phần tử con cùng type xếp thành list, chỉ so type là chưa đủ: khi list thêm/bớt/đảo phần tử, React cần biết phần tử nào là phần tử nào. Không có manh mối, React đành so theo vị trí — phần tử thứ nhất khớp phần tử thứ nhất, cứ thế. key chính là manh mối đó: nó gắn cho mỗi phần tử một identity ổn định qua các render. Chọn sai key dẫn thẳng tới lớp bug ở mục tiếp theo.
3. Index làm key: bằng chứng state gắn nhầm chỗ
Đây là bug kinh điển. Hai list y hệt nhau đặt cạnh nhau: một dùng key={index}, một dùng key={item.id}. Mỗi dòng có một counter riêng. Ta bấm +1 vào dòng đầu (Học Fiber) ở cả hai list, rồi chèn một item mới lên đầu. Bấm Run và đọc kỹ output:
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<div id="root"></div>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.7",
"react-dom/client": "https://esm.sh/react-dom@19.2.7/client?deps=react@19.2.7"
}
}
</script>
<script type="module">
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
const h = React.createElement;
function Row({ label }) {
const [count, setCount] = useState(0);
return h("li", null,
h("button", { className: "bump", onClick: () => setCount((c) => c + 1) }, label + " · " + count));
}
function List({ items, useIndexKey }) {
return h("ul", { className: useIndexKey ? "by-index" : "by-id" },
items.map((item, i) => h(Row, { key: useIndexKey ? i : item.id, label: item.label })));
}
function App() {
const [items, setItems] = useState([
{ id: "a", label: "Học Fiber" },
{ id: "b", label: "Viết test" },
]);
return h("div", null,
h("button", { id: "prepend",
onClick: () => setItems((prev) => [{ id: "c", label: "Đọc docs" }, ...prev]) }, "prepend"),
h(List, { items, useIndexKey: true }),
h(List, { items, useIndexKey: false }));
}
// createRoot commit bất đồng bộ: chờ tới khi DOM phản ánh thay đổi rồi mới đọc.
const until = (fn) => new Promise((resolve, reject) => {
let n = 0;
(function poll() {
let ok = false;
try { ok = fn(); } catch (e) {}
if (ok) return resolve();
if (++n > 300) return reject(new Error("timeout"));
setTimeout(poll, 16);
})();
});
const readList = (sel) => Array.from(document.querySelectorAll(sel + " li")).map((el) => el.textContent);
createRoot(document.getElementById("root")).render(h(App));
async function run() {
await until(() => document.querySelectorAll(".by-index li").length === 2); // đợi render đầu
document.querySelector(".by-index .bump").click();
document.querySelector(".by-index .bump").click();
document.querySelector(".by-id .bump").click();
document.querySelector(".by-id .bump").click();
await until(() => readList(".by-index")[0] === "Học Fiber · 2" && readList(".by-id")[0] === "Học Fiber · 2");
console.log("[index] trước prepend:", readList(".by-index"));
console.log("[id] trước prepend:", readList(".by-id"));
document.getElementById("prepend").click();
await until(() => document.querySelectorAll(".by-index li").length === 3);
console.log("[index] sau prepend:", readList(".by-index"));
console.log("[id] sau prepend:", readList(".by-id"));
console.log("→ index: count 2 dính theo vị trí, giờ gắn nhầm 'Đọc docs'");
console.log("→ id: count 2 vẫn đi theo item 'Học Fiber'");
}
run();
</script>Giải thích: sau khi chèn Đọc docs lên đầu:
key={index}: React so theo vị trí. Instance ở vị trí 0 (đang mangcount=2) giờ nhận labelĐọc docs→Đọc docs · 2. Số đếm 2 dính theo vị trí, gắn nhầm sang item mới, cònHọc Fibermất số đếm.key={item.id}: React nhận raid="a"(Học Fiber) vẫn là nó, chỉ đổi chỗ; item mớiid="c"là instance mới vớicount=0. Số đếm 2 đi đúng theo itemHọc Fiber.
Cùng dữ liệu, cùng thao tác, chỉ khác cách đặt key mà kết quả trái ngược. Với input không kiểm soát (uncontrolled) hoặc component nặng state, đây đúng là kiểu bug làm chữ bạn gõ “nhảy” sang dòng khác.
3.1. Khi nào index làm key vẫn ổn
key={index} không phải lúc nào cũng sai. Nó ổn khi list tĩnh, chỉ append vào cuối, và không bao giờ reorder, chèn giữa hay xóa giữa — khi ấy index trùng với identity. Vấn đề chỉ nổ ra khi vị trí và identity lệch nhau.
Ngược lại, đừng bao giờ dùng key={Math.random()}: mỗi render sinh key mới, React coi mọi phần tử là mới toanh và remount cả list mỗi lần — vừa mất state vừa chậm. key phải đến từ chính dữ liệu (một id ổn định), không phải từ vị trí, cũng không phải từ số ngẫu nhiên.
4. State sống ở vị trí trong cây, không “trong component”
Bug ở mục 3 gợi ra một sự thật ngược đời: state không sống bên trong component của bạn. Nó sống ở một vị trí trong cây mà React đang giữ. Cùng một component ở cùng một vị trí thì được giữ state; đổi vị trí hay đổi type thì mất. react.dev gọi bộ quy tắc này là Preserving and Resetting State.
4.1. Cùng type, cùng vị trí → giữ state
Miễn là ở cùng vị trí bạn vẫn render cùng một type component, React giữ nguyên state của nó — kể cả khi bạn đổi nhánh của một ternary, hay đổi props truyền vào. Demo dưới có một Counter nằm dưới ternary, mỗi nhánh truyền label khác nhau. Bấm Run:
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<div id="root"></div>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.7",
"react-dom/client": "https://esm.sh/react-dom@19.2.7/client?deps=react@19.2.7"
}
}
</script>
<script type="module">
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
const h = React.createElement;
function Counter({ label }) {
const [count, setCount] = useState(0);
return h("button", { id: "counter", onClick: () => setCount((c) => c + 1) }, label + " · " + count);
}
function App() {
const [branch, setBranch] = useState(true);
// toggle nằm ở vị trí 0; Counter luôn ở vị trí 1 → cùng type + cùng vị trí
return h("div", null,
h("button", { id: "toggle", onClick: () => setBranch((b) => !b) }, "đổi nhánh"),
branch ? h(Counter, { label: "Nhánh A" }) : h(Counter, { label: "Nhánh B" }));
}
// createRoot commit bất đồng bộ: chờ tới khi DOM phản ánh thay đổi rồi mới đọc.
const until = (fn) => new Promise((resolve, reject) => {
let n = 0;
(function poll() {
let ok = false;
try { ok = fn(); } catch (e) {}
if (ok) return resolve();
if (++n > 300) return reject(new Error("timeout"));
setTimeout(poll, 16);
})();
});
const counterText = () => (document.getElementById("counter") || {}).textContent;
createRoot(document.getElementById("root")).render(h(App));
async function run() {
await until(() => counterText() === "Nhánh A · 0"); // đợi render đầu
document.getElementById("counter").click();
document.getElementById("counter").click();
await until(() => counterText() === "Nhánh A · 2");
console.log("trước:", counterText());
document.getElementById("toggle").click();
await until(() => counterText() === "Nhánh B · 2");
console.log("sau khi đổi nhánh ternary:", counterText(), "→ cùng type + vị trí nên state giữ");
}
run();
</script>Giải thích: đổi nhánh từ Nhánh A sang Nhánh B, label đổi nhưng count vẫn là 2 — vì cả hai nhánh đều render Counter (cùng type) tại cùng một vị trí. Đây cũng là lý do {show && <X/>} không làm sibling đứng sau nó mất state: khi show là false, biểu thức trả về false nhưng vẫn chiếm một slot trong danh sách con, nên các sibling phía sau không bị xê dịch vị trí.
4.2. Chủ động remount bằng key
Lật ngược lại: đôi khi bạn muốn reset. Khi chuyển từ hồ sơ user này sang user khác trong cùng một form, bạn muốn mọi state cũ (kể cả input đang gõ dở) biến mất. Công cụ cho việc đó chính là key — mặt tích cực của thứ đã gây bug ở mục 3:
1
2
// Đổi userId → key đổi → React remount ProfileForm từ đầu, state cũ sạch sẽ
<ProfileForm key={userId} userId={userId} />Cùng type, cùng vị trí, nhưng key khác thì React vẫn coi là một cây mới và dựng lại từ đầu. File key-remount.test.tsx chứng minh điều này với một uncontrolled input: giữ nguyên key thì giá trị đang gõ được giữ lại; đổi key thì input dựng lại theo defaultValue mới. key vì thế là cách reset state có chủ đích — sạch hơn nhiều so với việc tự tay dọn từng field.
5. Fiber: cấu trúc dữ liệu cho phép tạm dừng
Ta đã nói render phase “ngắt được” mà chưa nói nhờ đâu. Câu chuyện lịch sử — vì sao React 16 viết lại toàn bộ reconciler — nằm ở bài changelog; ở đây ta đi sâu vào chính cấu trúc dữ liệu đó: Fiber.
5.1. Fiber node là gì
Trước Fiber, reconciler chạy đệ quy trên cây element — mà đệ quy thì bám vào call stack, đã bắt đầu là phải chạy tới hết, không dừng được. Fiber tách phần “công việc” ra khỏi call stack bằng cách dựng một cây node bền và mutable song song với cây element: mỗi component hay host element có một fiber node, mang type, stateNode (DOM node hoặc instance), memoizedProps, memoizedState (danh sách hooks dạng linked list), và flags (đánh dấu việc cần làm lúc commit).
Điểm mấu chốt để phân biệt: element tree là mô tả mới toanh, dựng lại và vứt đi mỗi lần render (rẻ). Fiber tree là nơi state thật sự sống — nó bền qua các render và bị React mutate tại chỗ. Khi ta nói “state ở vị trí” ở mục 4, “vị trí” đó chính là một fiber node.
5.2. child / sibling / return: cây thành linked list
Thay vì để con trong một mảng, mỗi fiber trỏ tới con đầu qua child, tới em kế qua sibling, và tới cha qua return. Ba con trỏ này biến việc duyệt cây từ đệ quy thành một vòng lặp tường minh: React đi xuống child, hết con thì sang sibling, hết sibling thì leo return để tìm sibling của cha. Vì trạng thái duyệt chỉ là “con trỏ đang ở fiber nào”, React có thể dừng bất cứ lúc nào và tiếp tục sau chỉ bằng cách lưu lại con trỏ đó.
View Mermaid diagram code
flowchart TD
App["App"] -- child --> Div["div"]
Div -- child --> H1["h1 · con trỏ lưu tại đây"]
H1 -- sibling --> Btn["button"]
Div -. return .-> App
H1 -. return .-> Div
Btn -. return .-> Div
style App fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Div fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Btn fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style H1 fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50Đoạn dưới dựng đúng cây fiber-like trong hình, duyệt bằng vòng lặp, dừng sau 2 đơn vị công việc rồi tiếp tục từ con trỏ đã lưu. Bấm Run:
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
32
33
34
35
36
37
38
39
function fiber(type) {
return { type, child: null, sibling: null, return: null };
}
const app = fiber("App");
const div = fiber("div");
const h1 = fiber("h1");
const button = fiber("button");
app.child = div; div.return = app;
div.child = h1; h1.return = div;
h1.sibling = button; button.return = div;
function next(node) {
if (node.child) return node.child;
let cur = node;
while (cur) {
if (cur.sibling) return cur.sibling;
cur = cur.return;
}
return null;
}
let current = app;
let processed = 0;
const budget = 2; // "hết thời gian" sau 2 đơn vị công việc
while (current && processed < budget) {
console.log("xử lý fiber:", current.type);
current = next(current);
processed++;
}
console.log("⏸ tạm dừng — con trỏ lưu tại:", current ? current.type : "(hết)");
// Resume từ đúng con trỏ đã lưu, không cần dựng lại call stack.
while (current) {
console.log("tiếp tục fiber:", current.type);
current = next(current);
}
console.log("✓ duyệt xong toàn cây");Giải thích: React duyệt tới h1 thì “hết thời gian”, lưu con trỏ lại và trả quyền điều khiển. Khi được gọi lại, nó đọc con trỏ và đi tiếp h1 → button — không cần dựng lại bất kỳ call stack nào. Chính khả năng lưu-và-tiếp-tục này là thứ đệ quy không làm được.
5.3. Double buffering: current và workInProgress
React giữ hai cây fiber cùng lúc: cây current đang hiển thị trên màn hình, và cây workInProgress mà render phase đang dựng dở. Mỗi fiber trỏ sang bản sao của nó ở cây kia qua con trỏ alternate. React dựng và mutate thoải mái trên workInProgress mà không đụng gì tới cây đang hiển thị; đến commit phase, nó chỉ việc lật con trỏ root.current sang cây mới trong một thao tác.
Nhờ vậy người dùng không bao giờ thấy “cây làm dở”: hoặc là cây cũ trọn vẹn, hoặc là cây mới trọn vẹn. Và nếu một update ưu tiên cao hơn xen vào giữa chừng, React chỉ cần vứt bỏ workInProgress — cây current vẫn nguyên vẹn, không tốn công dọn dẹp.
6. Work loop và lanes: vì sao render ngắt được
Ghép mọi thứ lại: render phase là một work loop xử lý từng fiber một. Sau mỗi đơn vị công việc, React hỏi shouldYield() — đại ý “đã hết thời gian trong frame này chưa, hay có update nào gấp hơn không?”. Nếu có, nó nhường lại main thread cho trình duyệt (để cuộn, gõ, animation không giật), rồi tiếp tục — hoặc làm lại — sau. Đoạn dưới mô phỏng vòng lặp đó, với budget đếm bằng đơn vị cho tất định. Bấm Run:
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
const work = ["App", "section", "h1", "ul", "li", "button", "span"];
const total = work.length;
let cursor = 0;
// Update gấp hơn "đến" ngay sau đơn vị thứ 4 — mô phỏng tất định, đếm bằng unit.
const urgentArrivesAt = 4;
function shouldYield(unitsDone) {
return unitsDone === urgentArrivesAt;
}
function workLoop() {
while (cursor < total) {
console.log(`xử lý fiber ${work[cursor]} (unit ${cursor + 1}/${total})`);
cursor++;
if (shouldYield(cursor) && cursor < total) {
console.log("⏸ yield — nhường main thread cho update gấp hơn");
return false; // chưa xong: cursor được giữ nguyên để resume
}
}
return true;
}
workLoop(); // slice 1: chạy tới khi yield
console.log("... xử lý update gấp, xong ...");
console.log("tiếp tục từ fiber đã lưu:", work[cursor]);
const done = workLoop(); // slice 2: resume từ cursor đã lưu
console.log(done ? "✓ commit: ghi toàn bộ cây xuống DOM một lần" : "chưa xong");Giải thích: loop chạy 4 đơn vị rồi yield; cursor được giữ nguyên nên slice sau tiếp tục đúng từ li. Commit chỉ xảy ra khi toàn bộ cây đã dựng xong — đó là lý do render ngắt được nhưng người dùng không bao giờ thấy màn hình cập nhật nửa vời.
React quyết định cái gì “gấp hơn” bằng lanes: mỗi update được gắn một mức ưu tiên (một bit trong bitmask) — click và gõ phím là lane ưu tiên cao, một startTransition là lane thấp hơn, có thể bị gián đoạn. Đây là chi tiết internal: tên và cơ chế lanes có thể đổi giữa các bản React, bạn không gọi thẳng chúng. Bạn dùng mức ưu tiên này qua API như startTransition, useDeferredValue, Suspense — cách dùng nằm ở bài concurrency. Ở đây chỉ cần nắm phần máy móc: có work loop chia nhỏ được, có lane để so ưu tiên, nên một cú click luôn thắng một transition đang chạy dở.
7. Áp dụng vào code hằng ngày
Bốn điều rút ra, mỗi điều chốt về một cơ chế đã chứng minh ở trên:
keyphải đến từ dữ liệu, không phải index hayMath.random()(mục 3). Đây là nguyên nhân số một của bug “state gắn nhầm dòng”.- Đừng định nghĩa component bên trong component. Mỗi lần parent render, nó tạo một function mới →
typekhác → heuristic 2.1 remount cả subtree, xóa state và chạy lại mọi effect. Demo dưới, bấm Run:
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<div id="root"></div>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@19.2.7",
"react-dom/client": "https://esm.sh/react-dom@19.2.7/client?deps=react@19.2.7"
}
}
</script>
<script type="module">
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
const h = React.createElement;
let badMounts = 0, goodMounts = 0;
// ✅ GoodInner hoisted ra ngoài
function GoodInner() {
const [count, setCount] = useState(() => { goodMounts++; return 0; });
return h("button", { className: "good", onClick: () => setCount((c) => c + 1) }, "good " + count);
}
function App() {
const [rev, setRev] = useState(0);
// ❌ BadInner định nghĩa trong thân App
function BadInner() {
const [count, setCount] = useState(() => { badMounts++; return 0; });
return h("button", { className: "bad", onClick: () => setCount((c) => c + 1) }, "bad " + count);
}
return h("div", { "data-rev": rev },
h("button", { id: "rerender", onClick: () => setRev((n) => n + 1) }, "re-render parent"),
h(BadInner), h(GoodInner));
}
// createRoot commit bất đồng bộ: chờ tới khi DOM phản ánh thay đổi rồi mới đọc.
const until = (fn) => new Promise((resolve, reject) => {
let n = 0;
(function poll() {
let ok = false;
try { ok = fn(); } catch (e) {}
if (ok) return resolve();
if (++n > 300) return reject(new Error("timeout"));
setTimeout(poll, 16);
})();
});
const badText = () => (document.querySelector(".bad") || {}).textContent;
const goodText = () => (document.querySelector(".good") || {}).textContent;
createRoot(document.getElementById("root")).render(h(App));
async function run() {
await until(() => badText() === "bad 0"); // đợi render đầu
document.querySelector(".bad").click(); document.querySelector(".bad").click();
document.querySelector(".good").click(); document.querySelector(".good").click();
await until(() => badText() === "bad 2" && goodText() === "good 2");
console.log("trước re-render → bad:", badText(), "· good:", goodText());
document.getElementById("rerender").click();
await until(() => badMounts === 2);
console.log("sau re-render → bad:", badText(), "· good:", goodText());
console.log("số lần mount → BadInner:", badMounts, "(remount mỗi render) · GoodInner:", goodMounts, "(chỉ 1 lần)");
}
run();
</script>- Reset có chủ đích thì dùng
key(mục 4.2), đừng viết tayuseEffectdọn từng field. - Chi phí reconciliation chỉ đáng lo với list vài nghìn item hoặc cây rất sâu. Lúc đó cách chữa không phải “diff nhanh hơn” mà là cắt bớt số lần render thừa — bằng memo hoá (
memo,useMemo) hoặc React Compiler. Đó là một chủ đề riêng, không bàn ở đây.
Tóm lại: diffing quyết định cái gì thay đổi, còn Fiber quyết định khi nào làm việc đó. Element là mô tả rẻ vứt đi mỗi render, fiber là nơi state thật sự sống theo vị trí, và key là cách bạn nói cho React biết đâu là “vẫn là nó”. Nắm ba điều này, những bug “ma” đầu bài trở thành hành vi đoán trước được. Ngay hôm nay, hãy mở một component list bất kỳ của bạn ra và soát lại: key của nó đến từ dữ liệu, hay từ index?