Cơ chế render của React — Fiber, reconciliation, batching, concurrent rendering
Câu hỏi mở màn gần như chắc chắn gặp: "Khi gọi setState, chuyện gì xảy ra?". Người trả lời
"React so sánh Virtual DOM rồi cập nhật DOM thật" chỉ qua được vòng junior. Tech lead phải nói được: vì sao render
phải là pure function, vì sao key quyết định state thuộc về ai, update nào được phép ngắt update nào,
và khi nào React không phải lựa chọn đúng.
Đã verify gì. Mọi số liệu trong bài chạy thật trên React 19.3.0 + react-dom 19.3.0, Node 22.23.3, jsdom 26.1
(container node:22-bookworm-slim tạm). So sánh batching chạy thêm trên React 17.0.2. Số ms trong jsdom chậm hơn
browser thật — hãy nhìn tỉ lệ và thứ tự, đừng nhìn giá trị tuyệt đối. Phần lịch sử và các con số của Chrome
(INP, long task) lấy từ tài liệu chính thức, không dựng lab được. Lệnh chạy lại ở phụ lục.
Bảng thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| render (trong React) | React gọi hàm component để lấy ra cây element mới. Chưa đụng DOM. |
| commit | Pha áp các thay đổi đã tính được vào DOM thật. Chạy một mạch, không bị ngắt. |
| reconciliation | So cây mới với cây cũ để biết node nào giữ, node nào tạo mới, node nào xoá. |
| Fiber | Một object đại diện cho một component trong cây; cũng là "một đơn vị công việc" mà React có thể tạm dừng. |
| lane | Mức ưu tiên của một update, lưu dạng bitmask (dãy bit). Click = SyncLane, transition = TransitionLane... |
| batching | Gộp nhiều setState thành một lần render. |
| concurrent rendering | React có thể render dở, nhả luồng cho việc gấp hơn, rồi làm tiếp hoặc vứt bỏ phần đã render. |
| bailout | React bỏ qua render một component vì biết chắc kết quả không đổi. |
| INP (Interaction to Next Paint) | Chỉ số Core Web Vitals: từ lúc người dùng tương tác tới lúc màn hình vẽ lại. Tốt là dưới 200ms. |
Câu trả lời 60 giây
"setStatekhông đổi DOM ngay. Nó đẩy một update vào hàng đợi của Fiber, gắn một lane (mức ưu tiên) theo nguồn gốc: click thì SyncLane,startTransitionthì TransitionLane. React gộp các update trong cùng tick (từ React 18 gộp cả trongsetTimeoutvà promise). Sau đó có hai pha. Render: gọi lại component, so cây mới với cây cũ theo hai luật — kháctypethì huỷ cả nhánh, danh sách thì so theokey. Pha này phải pure vì React có thể chạy lại hoặc vứt nó. Commit: áp thay đổi vào DOM một mạch, chạyuseLayoutEffecttrước khi browser vẽ,useEffectsau. Transition chỉ làm việc render ngắt được, không làm nó nhanh hơn — và chỉ event thật của người dùng mới ngắt được transition,setStatetừ timer thì không."
1. Bài toán gốc và bối cảnh ra đời
Trước React (khoảng 2010–2013) có hai kiểu làm UI phổ biến, và cả hai đều vỡ khi app lớn:
| Cách cũ | Cách chạy | Vỡ ở đâu |
|---|---|---|
| jQuery / DOM thủ công | Nghe event, tự tìm node rồi sửa: $('#count').text(n) | Mỗi tính năng mới phải nhớ mọi chỗ cần cập nhật. N trạng thái × M chỗ hiển thị = bug "chỗ này đã đổi, chỗ kia chưa". |
| Two-way binding (AngularJS 1, Knockout) | Gắn model với view hai chiều, framework tự dò thay đổi (dirty checking) | Thay đổi lan theo nhiều hướng, khó biết cái gì đổi cái gì. Dirty checking chạy lại nhiều vòng khi cây lớn. |
React do Jordan Walke viết ở Facebook (bản thử FaxJS 2011), dùng cho News Feed năm 2011, Instagram năm 2012, mở mã nguồn tháng 5/2013. Ý tưởng lúc đó bị chê là "điên": mỗi lần state đổi, cứ vẽ lại cả UI như thể lần đầu, để thư viện tự tìm phần khác biệt. Lập trình viên chỉ mô tả "với state này, UI trông thế này" — không bao giờ viết "đổi từ A sang B thế nào".
Các mốc lớn sau đó đều là hệ quả của ý tưởng này:
- 2017 — React 16 (Fiber): viết lại lõi. Bản cũ (stack reconciler) render đệ quy một mạch, không dừng được; cây lớn thì khoá luồng chính hàng trăm ms. Fiber biến render thành vòng lặp có thể tạm dừng.
- 2019 — React 16.8 (Hooks): state gắn theo thứ tự gọi hook, bỏ class.
- 2022 — React 18: bật concurrent rendering qua
createRoot, automatic batching,useTransition. - 12/2024 — React 19: Actions,
use(), Server Components ổn định; bỏ hẳnReactDOM.render. - 10/2025 — React Compiler 1.0, React 19.2 (
<Activity>,useEffectEvent), và React chuyển sang React Foundation thuộc Linux Foundation.
Nguồn các mốc: blog react.dev. Chưa verify bằng lab (không cần — là lịch sử). Riêng việc React 19 bỏ API cũ
thì đã chạy thử: typeof ReactDOM.render trên 19.3.0 trả undefined.
Suy ra: React được tối ưu cho gì, KHÔNG tối ưu cho gì
| Tối ưu cho | Không tối ưu cho |
|---|---|
| UI nhiều trạng thái đan xen (dashboard, editor, form nhiều bước, app kiểu SPA) | Trang nội dung ít tương tác (blog, landing page) — gửi runtime JS mà gần như không cần |
| Team lớn: component đóng gói, luồng dữ liệu một chiều dễ đọc | Hiệu năng thô: "vẽ lại rồi so" luôn tốn hơn cách sửa đúng node cần sửa (Svelte, Solid, Vue Vapor làm vậy) |
| Chạy nhiều nền tảng (web, React Native) cùng một mô hình | Animation 60fps điều khiển bằng state — mỗi frame là một lần render; nên dùng CSS/Web Animations |
| Hệ sinh thái và tuyển người | Team backend nhỏ chỉ cần vài form — Livewire/Inertia/HTMX rẻ hơn nhiều |
2. Mô hình tư duy cốt lõi
Chỉ cần nhớ một phương trình và hai pha:
UI = f(state). Render là tính toán thuần (pure). Commit là bước duy nhất có side effect lên DOM.
flowchart LR A["setState / props mới"] --> B["Xếp hàng update
gắn lane (ưu tiên)"] B --> C["RENDER
gọi component, so cây cũ-mới
pure, ngắt được, có thể bị vứt"] C --> D["COMMIT
áp vào DOM một mạch
không ngắt được"] D --> E["useLayoutEffect
trước khi browser vẽ"] E --> F["Browser paint"] F --> G["useEffect
sau khi vẽ"]
Hầu hết "luật" của React chỉ là hệ quả của mô hình này — không cần học thuộc riêng:
| Luật / tính năng | Vì sao nó phải tồn tại |
|---|---|
| Render phải pure, không side effect | Pha render có thể bị chạy lại hoặc bị vứt (concurrent). Side effect trong render sẽ chạy thừa. |
| Strict Mode render 2 lần ở dev | Cách rẻ nhất để lộ code không pure: chạy 2 lần mà kết quả khác nhau là có bug. |
key trong danh sách | Khi so cây, React cần biết "phần tử này là ai" để giữ đúng state cho đúng item. |
| State phải immutable | Bailout dựa trên Object.is. Sửa trực tiếp object thì tham chiếu không đổi → React tưởng không có gì thay đổi. |
| Rules of Hooks | State của hook lưu theo thứ tự gọi trên Fiber, không theo tên. Gọi trong if là lệch thứ tự. |
useTransition | Vì render là pure và nằm tách khỏi commit, React được phép bỏ dở render này để làm việc gấp hơn. |
memo / React Compiler | Mặc định re-render cả nhánh con. Memo là cách nói "input không đổi thì bỏ qua phép tính". |
3. Internals và quyết định thiết kế
3.1 Fiber: cây object, hai bản, duyệt bằng vòng lặp
Mỗi component (và mỗi thẻ DOM) có một Fiber. Các field quan trọng:
{
type, key, // dùng để so khớp khi reconcile
stateNode, // DOM node thật (với thẻ host) hoặc null
child, sibling, return, // con đầu, anh em kế, cha → duyệt cây không cần đệ quy
memoizedProps, memoizedState, // memoizedState = danh sách liên kết các hook theo thứ tự gọi
updateQueue, lanes, // update đang chờ và mức ưu tiên của chúng
alternate // trỏ sang bản còn lại (current ↔ workInProgress)
}
Double buffering (hai bộ đệm): React giữ cây current (đang hiển thị) và dựng cây
workInProgress ở bên cạnh. Render xong thì commit chỉ là đổi con trỏ. Nhờ vậy render dở có thể vứt đi mà
màn hình không bao giờ thấy trạng thái nửa vời.
flowchart TB
subgraph WL["Work loop (1 Fiber = 1 đơn vị việc)"]
direction LR
B1["beginWork
gọi component, reconcile con"] --> C1["completeWork
tạo/đánh dấu DOM cần đổi"]
C1 --> Y{"shouldYield?
đã quá ~5ms"}
Y -- "chưa" --> B1
Y -- "rồi" --> P["nhả luồng cho browser
(MessageChannel)"]
P --> B1
end
WL --> CM["commitRoot
mutation → layout effects → (sau paint) passive effects"]
Đánh đổi: tốn gấp đôi bộ nhớ cho cây, và code lõi phức tạp hơn nhiều so với đệ quy. Đổi lại được khả năng
dừng giữa chừng. Khoảng thời gian 5ms lấy từ source scheduler: frameInterval = 5.
Lưu ý: React chỉ dừng được giữa hai Fiber — một component tự nó chạy 50ms thì không ai ngắt được.
3.2 Reconciliation: hai heuristic đổi độ phức tạp O(n³) lấy O(n)
Thuật toán tổng quát để tìm số thao tác ít nhất biến cây A thành cây B tốn O(n³). React chấp nhận không tối ưu tuyệt đối để được O(n), bằng hai giả định:
- Khác
type→ huỷ cả nhánh, dựng lại.<div>thành<section>, hoặc<A/>thành<B/>: toàn bộ state bên dưới mất. - Trong danh sách,
keylà danh tính. Cùng key = cùng phần tử, dù vị trí đổi.
Giả định 1 sinh ra một bẫy rất hay gặp: định nghĩa component bên trong component khác. Mỗi lần cha render,
hàm con là một function mới → type mới → React huỷ và mount lại. Lab đo được:
Inner định nghĩa trong Parent, 5 lần render Parent -> Inner mount lại: 5 lần
3.3 Lane: update nào được ngắt update nào
Mỗi update mang một lane. Lane là một bit trong số nguyên 31 bit; bit càng thấp càng gấp. Gộp nhiều lane bằng phép OR, kiểm tra bằng AND — rất rẻ.
| Nguồn update | Lane | Có time-slice (nhả luồng)? |
|---|---|---|
flushSync, click, keydown, input (event rời rạc) | SyncLane | Không — render một mạch |
| Scroll, mousemove (event liên tục) | InputContinuousLane | Không |
setState từ setTimeout, promise, fetch xong | DefaultLane (giá trị 32) | Không |
startTransition, useDeferredValue | TransitionLanes | Có |
Chi tiết ít người biết, lấy thẳng từ source getNextLanes của react-dom 19.3.0 và đã tái hiện bằng lab:
// react-dom-client.development.js, getNextLanes
suspendedLanes >= rootHasPendingCommit ||
(32 === suspendedLanes && 0 !== (rootHasPendingCommit & 4194048))
? wipLanes // giữ việc đang làm, KHÔNG ngắt
: nextLanes;
// 32 = DefaultLane, 4194048 = mặt nạ TransitionLanes
Nghĩa là: DefaultLane không được ngắt một transition đang render. Chỉ update gấp hơn (Sync, InputContinuous) mới ngắt được. Hệ quả thực tế ở mục 5.3.
3.4 Batching: gom update, render một lần
setState chỉ đẩy update vào hàng đợi và lên lịch (qua microtask) một lần xử lý cho root. Mọi
setState trong cùng tick rơi vào cùng lần xử lý đó. Trước React 18, việc gộp chỉ có trong event handler của
React; code trong setTimeout/promise chạy ngoài "vùng batching" nên mỗi setState render một lần.
Lab chạy cùng một component trên hai bản:
React 17.0.2 evt 3 lần setState -> số lần render: 1
React 17.0.2 timeout 3 lần setState -> số lần render: 3
React 17.0.2 promise 3 lần setState -> số lần render: 3
React 19.3.0 evt setState -> số lần render: 1 (a=2 b=1)
React 19.3.0 timeout setState -> số lần render: 1 (a=4 b=2)
React 19.3.0 promise setState -> số lần render: 1 (a=6 b=3)
React 19.3.0 flush setState -> số lần render: 2 (a=7 b=4) ← 2 lần flushSync = 2 render
Đánh đổi: batching làm state không đổi ngay sau khi gọi setState — đọc biến state ngay dòng sau vẫn thấy giá
trị cũ. Cần DOM cập nhật ngay (đo kích thước, focus) thì dùng flushSync, chấp nhận mất batching.
3.5 Pha commit: thứ tự effect
Commit chạy theo thứ tự cố định: đổi DOM → chạy useLayoutEffect (đồng bộ, browser chưa vẽ) → browser vẽ
→ chạy useEffect. Trong cùng một pha, con chạy trước cha. Vì vậy:
- Đo DOM rồi sửa lại vị trí (tooltip) →
useLayoutEffect, nếu không người dùng thấy nháy một frame. - Việc nặng trong
useLayoutEffectchặn paint → tăng INP. Mặc định dùnguseEffect.
4. Đảm bảo và giới hạn
| React đảm bảo | React KHÔNG đảm bảo |
|---|---|
| Commit nguyên khối: người dùng không thấy cây nửa mới nửa cũ (trong một root) | Render chạy đúng một lần. Nó có thể chạy nhiều lần, hoặc chạy rồi bị vứt. |
Từ React 18: mọi setState trong cùng tick được gộp (đã đo ở mục 3.4) | State đổi ngay sau setState |
Update cùng lane được xử lý theo thứ tự gọi; updater function c => c + 1 nhận giá trị mới nhất | Transition chạy xong. Transition mới hơn cùng loại sẽ thay thế cái cũ (lab: "a", "ab" bị bỏ, chỉ render "abc"). |
| Layout effect chạy trước paint, con trước cha | Store ngoài React được đọc nhất quán — cần useSyncExternalStore (xem bài state) |
Giới hạn cứng:
- 50 update lồng nhau (
NESTED_UPDATE_LIMIT = 50trong source) → lỗiMaximum update depth exceeded. Thường dosetStatetronguseEffectvới dependency đổi mỗi lần. - Chi phí re-render tỉ lệ với kích thước nhánh: đổi state ở gốc = gọi lại mọi component con không được memo.
- Concurrent không giảm tổng công việc. Theo tinh thần định luật Amdahl: phần CPU phải làm vẫn vậy, transition chỉ chia nhỏ và xếp lại thứ tự để việc gấp chen lên trước. Danh sách 300 item tốn ~400ms thì vẫn tốn ~400ms (lab: commit danh sách ở 661ms dù có transition).
- Độ mịn của việc ngắt = 1 Fiber. Component nào tự chạy lâu hơn 5ms là khoảng không ngắt được.
5. Failure modes
5.1 Render quá chậm → gõ phím bị lag
Lab: ô input + danh sách 300 item (mỗi item ~1ms CPU). Gõ "a", "ab", "abc", mỗi phím cách 40ms. Mỗi phím cập nhật ô input (gấp) và lọc danh sách (không gấp).
=== discrete-no-transition === ← không dùng transition
539ms commit ô input="a" (trễ 539ms sau khi gõ)
967ms commit ô input="ab" (trễ 415ms sau khi gõ)
1957ms commit ô input="abc" (trễ 983ms sau khi gõ)
=== transition === ← setQuery bọc trong startTransition
5ms commit ô input="a" (trễ 5ms sau khi gõ)
49ms commit ô input="ab" (trễ 3ms sau khi gõ)
91ms commit ô input="abc" (trễ 2ms sau khi gõ)
661ms commit danh sách query="abc" ← "a" và "ab" bị vứt, chỉ render bản cuối
Hệ quả: chậm, không mất dữ liệu. Người dùng thấy chữ hiện trễ gần 1 giây — trên browser thật đo bằng INP.
5.2 key bằng index → state dính sai dòng
Mỗi dòng có state riêng (ô ghi chú). Xoá dòng đầu:
key=index sau khi xoá An: Binh → ghi chú của An | Cuong → ghi chú của Binh
key=id sau khi xoá An: Binh → ghi chú của Binh | Cuong → ghi chú của Cuong
Hệ quả: dữ liệu sai chỗ, không có lỗi nào hiện ra. Với form (ô input không kiểm soát, checkbox), người dùng có thể lưu nhầm dữ liệu của dòng này vào dòng khác. Đây là bẫy nổ trễ: danh sách chỉ thêm vào cuối thì chạy đúng, hỏng khi ai đó thêm tính năng xoá/sắp xếp.
5.3 setState từ timer/WebSocket không ngắt được transition
Cùng lab gõ phím, nhưng lần này cập nhật ô input từ setTimeout (giống update đến từ WebSocket, polling):
=== default-from-timer ===
2ms commit ô input="a" (trễ 2ms sau khi gõ)
507ms commit danh sách query="a"
540ms commit ô input="abc" (trễ 454ms sau khi gõ) ← "ab" không bao giờ hiện
1072ms commit danh sách query="abc"
Transition đang render dở, update DefaultLane phải chờ (luật ở mục 3.3). Ai nghĩ "đã bọc transition thì UI luôn
mượt" sẽ bất ngờ khi dữ liệu realtime (giá cổ phiếu, chat) bị đứng. Chữa: update realtime cần gấp thì bọc
flushSync (có giá: mất batching), hoặc giảm độ nặng của transition (ảo hoá danh sách).
5.4 Component định nghĩa trong component → mount lại mỗi lần render
Đo được 5 lần mount cho 5 lần render (mục 3.2). Triệu chứng thật: ô input mất focus sau mỗi phím gõ, state con reset,
useEffect của con chạy lại (gọi API lại). Eslint không bắt được trong mọi trường hợp — phải review.
5.5 Vòng lặp update vô hạn
useEffect(() => setX({...}), [x]) → mỗi lần set tạo object mới → effect chạy lại. React dừng ở 50 lần lồng
nhau với Maximum update depth exceeded. Bản chạy trong useEffect đôi khi không báo lỗi mà
chỉ đốt CPU và gọi API liên tục — chính là sự cố Cloudflare 9/2025 (xem bài Hooks).
Phát hiện và phục hồi
| Triệu chứng | Công cụ |
|---|---|
| Gõ/click bị trễ | Chrome Performance panel: tìm long task > 50ms. Web Vitals INP trên RUM (đo người dùng thật). |
| Render thừa | React DevTools Profiler → bật "Record why each component rendered". |
| State sai dòng | Không có công cụ tự bắt. Lint rule react/no-array-index-key + review. |
| Mount lại liên tục | Đặt console.count trong useEffect(() => ..., []); DevTools thấy component "mounted" lặp lại. |
6. Vận hành trong production
Metric cần theo dõi
| Metric | Ngưỡng | Liên quan cơ chế render ở đâu |
|---|---|---|
| INP (p75) | < 200ms tốt, > 500ms kém | Render đồng bộ quá lâu sau click/gõ phím |
| Long task | > 50ms là long task | Một lần render/commit lớn |
| JS bundle size | Theo ngân sách team (vd < 200KB gzip cho route đầu) | Runtime React + code component; thời gian parse/execute trên máy yếu |
| Lỗi hydration (nếu SSR) | 0 | Xem bài SSR/RSC |
Thu INP từ người dùng thật bằng thư viện web-vitals gửi về backend/APM. (Chưa verify trong lab — cần browser thật.)
Cấu hình ảnh hưởng lớn
- Bản build production (
NODE_ENV=production): bản dev có thêm kiểm tra, cảnh báo, Strict Mode chạy 2 lần — chậm hơn nhiều. Lab đã thấy: Strict Mode chỉ nhân đôi render/effect ở dev, production chạy 1 lần. - Profiling build (
react-dom/profiling) khi cần<Profiler onRender>chạy trên production thật. - React Compiler: giảm re-render tự động, nhưng phụ thuộc hình dạng code (xem bài Hooks — lab thấy compiler bỏ memo khi scope vắt qua hook).
Upgrade / migration
- 17 → 18: đổi
ReactDOM.rendersangcreateRootmới bật batching và concurrent. Để nguyên thì chạy như 17. - 18 → 19: API cũ bị xoá hẳn (đã kiểm:
render,hydrate,findDOMNodeđềuundefinedtrên 19.3). Thư viện bên thứ ba cũ còn gọi chúng sẽ crash khi chạy, không phải lúc build. Chạy codemod chính thức rồi test toàn bộ luồng chính. - Nâng cấp là việc của cả hệ sinh thái: router, form, UI kit phải hỗ trợ bản mới. Kiểm tra peerDependencies trước khi hứa ngày.
Gánh nặng cho team
Bug render hiếm khi báo lỗi — nó là "chậm" hoặc "sai chỗ". Người trực cần biết đọc Profiler và Performance panel.
Lúc 3h sáng, câu hỏi đầu tiên luôn là: "Release gần nhất có đổi key, đổi cấu trúc cây, hay thêm state ở
component gốc không?"
7. So sánh và ma trận quyết định
| Tiêu chí | React 19 | Vue 3 | Svelte 5 | SolidJS | Angular 20+ | Livewire / Inertia (Laravel) |
|---|---|---|---|---|---|---|
| Cơ chế cập nhật | Re-render nhánh + diff | Reactivity proxy + VDOM (Vapor mode bỏ VDOM) | Compiler + signals (runes), không VDOM | Signals, cập nhật đúng node | Signals + zoneless | Server render, gửi HTML/props |
| Hiệu năng thô | Trung bình (cần memo/compiler) | Tốt | Rất tốt | Rất tốt | Tốt | Phụ thuộc round-trip mạng |
| Độ phức tạp mô hình | Trung bình–cao (closure, effect, deps) | Thấp–trung bình | Thấp | Trung bình (dễ nhầm như React) | Cao | Thấp cho dev PHP |
| Hệ sinh thái | Lớn nhất | Lớn | Vừa | Nhỏ | Lớn, "có sẵn pin" | Gắn với Laravel |
| Tuyển người (VN) | Dễ nhất | Dễ | Khó | Rất khó | Vừa (doanh nghiệp) | Dev Laravel làm được |
| Độ trưởng thành | Cao, có Foundation | Cao | Vừa | Vừa | Cao, Google | Cao trong vùng Laravel |
Dùng React khi: UI tương tác dày, team frontend riêng hoặc sẽ lớn, cần React Native, cần hệ sinh thái lớn (component library, form, chart).
Tránh React khi: trang chủ yếu là nội dung; team toàn backend PHP cần vài màn CRUD (Livewire/Inertia rẻ hơn); UI cần cập nhật hàng nghìn node nhiều lần mỗi giây (canvas, bảng realtime cực lớn — cân nhắc Solid/Svelte hoặc vẽ bằng canvas).
8. Góc nhìn tech lead: tác động tổ chức
Viết theo dạng ADR để chép được:
| Mục ADR | Nội dung |
|---|---|
| Bối cảnh | Sản phẩm có N màn tương tác dày, team M dev frontend, backend Laravel. |
| Quyết định | Dùng React 19 cho các màn tương tác; phần nội dung giữ server-render. |
| Hệ quả tích cực | Tuyển dễ; component tái dùng; tách team FE/BE qua API contract. |
| Hệ quả tiêu cực | Thêm một build pipeline, một runtime JS, một lớp API; bug hiệu năng khó thấy (cần người biết Profiler); chi phí nâng cấp major mỗi 1–2 năm. |
| Rủi ro dài hạn | React thì ít rủi ro vendor (có Foundation, MIT). Rủi ro nằm ở framework trên nó (Next.js gắn với Vercel) và thư viện bên thứ ba bỏ rơi. |
| Exit strategy | Giữ logic nghiệp vụ trong module TS thuần (tính giá, validate, format) không import React; gọi API qua một lớp client riêng. Khi đổi framework, chỉ viết lại lớp view. Có thể thay dần từng màn (strangler pattern — thay từng phần, hệ cũ và mới chạy song song). |
Tác động tới tốc độ team: dev backend chuyển sang React mất khoảng vài tuần để viết được, nhưng lâu hơn để tránh được bẫy closure/effect/key. Onboarding nên có một buổi riêng về "mô hình render" — chính là bài này — trước khi cho code tính năng.
9. Anti-patterns và bài học thực tế
- Key bằng index hoặc
Math.random(). Index: state dính sai dòng (lab 5.2). Random: mỗi render là key mới → mount lại toàn bộ danh sách. - Định nghĩa component trong component (lab: 5/5 lần mount lại). Chuyển ra ngoài, truyền dữ liệu qua props.
- Đẩy state lên quá cao: một ô input ở gốc app làm cả trang re-render mỗi phím. Đặt state gần nơi dùng nhất (colocation — đặt cạnh nhau).
- Tin rằng
useTransitionlàm code nhanh hơn. Nó chỉ đổi thứ tự. Danh sách 10.000 dòng vẫn cần ảo hoá (virtualization — chỉ render phần đang nhìn thấy). - Side effect trong render (gọi API, ghi log, tăng biến global). Concurrent có thể chạy render nhiều lần. Chính lab React Compiler ở bài Hooks cũng từ chối compile component làm vậy.
- Đọc state ngay sau
setStaterồi dùng giá trị đó — luôn là giá trị cũ. - Memo mọi thứ "cho chắc": mỗi
memotốn một lần so props; props là object/hàm mới mỗi lần thì memo vô dụng (lab bài Hooks:MemoObjvẫn render 10/10 lần).
Điều tài liệu không nói rõ nhưng người làm lâu đều biết: hầu hết vấn đề hiệu năng React không nằm ở diff, mà
nằm ở render quá nhiều component không cần và component tự nó quá nặng. Sửa bằng kiến trúc state
(đặt đúng chỗ, tách context, dùng store có selector) hiệu quả hơn rải useMemo.
10. Câu hỏi phụ interviewer hay hỏi tiếp
H: Virtual DOM có nhanh hơn DOM thật không?
Đ: Không. Làm thêm một bước (tạo cây, so sánh) luôn chậm hơn sửa đúng node cần sửa. Virtual DOM đổi hiệu năng lấy
mô hình lập trình: viết UI như hàm của state mà vẫn nhanh "đủ dùng". Svelte/Solid chứng minh không cần VDOM vẫn
khai báo được.
H: Vì sao cần key? Dùng index được không?
Đ: Key là danh tính khi so danh sách. Index chỉ an toàn khi danh sách không bao giờ chèn/xoá/sắp xếp và item không có
state. Lab: xoá dòng đầu thì ghi chú của An sang Binh.
H: setState là đồng bộ hay bất đồng bộ?
Đ: Đúng hơn là "được lên lịch". Nó xếp hàng rồi React gộp lại. Từ React 18 gộp ở mọi nơi. Cần DOM cập nhật ngay thì
flushSync.
H: Fiber giải quyết vấn đề gì?
Đ: Reconciler cũ đệ quy một mạch, cây lớn khoá luồng chính. Fiber biến cây thành danh sách liên kết duyệt bằng vòng lặp,
có thể dừng giữa hai Fiber, gắn ưu tiên cho từng update, và vứt bỏ render dở nhờ double buffering.
H: useTransition khác debounce thế nào?
Đ: Debounce đợi một khoảng cố định rồi mới làm — máy mạnh cũng phải đợi. Transition bắt đầu ngay, nhường khi có việc gấp,
vứt bản cũ khi có bản mới. Nhưng transition không giảm số request mạng — gọi API theo từng phím vẫn cần debounce.
H: Khi nào transition không giúp được?
Đ: (1) Một component đơn lẻ tự chạy lâu — không ngắt được giữa chừng. (2) Update đến từ timer/WebSocket (DefaultLane)
không ngắt được transition — lab đo thấy trễ 454ms. (3) Việc chậm nằm ở commit (DOM quá lớn), không ở render.
H: useLayoutEffect vs useEffect?
Đ: Layout effect chạy đồng bộ sau khi đổi DOM, trước khi vẽ — dùng để đo và sửa layout, tránh nháy. Effect chạy sau khi vẽ,
không chặn paint. Mặc định dùng useEffect.
H: Re-render có nghĩa là DOM bị vẽ lại?
Đ: Không. Re-render là gọi lại hàm component. Nếu kết quả giống cũ, commit không đổi gì trên DOM. Nhưng gọi lại hàm vẫn
tốn CPU — đó là lý do có memo.
Phụ lục: lab đã chạy
Môi trường: Docker node:22-bookworm-slim (Node 22.23.3), react 19.3.0, react-dom 19.3.0, jsdom 26.1.0,
tsx 4.23. So sánh: react 17.0.2. Container tạm, mount một thư mục trống, không đụng service nào đang chạy.
mkdir rlab && cd rlab
docker run -d --name react-lab -v "$PWD":/lab -w /lab node:22-bookworm-slim sleep infinity
docker exec react-lab sh -c 'npm init -y && npm pkg set type=module && npm i react@19.3.0 react-dom@19.3.0 jsdom@26 tsx'
echo '{ "compilerOptions": { "jsx": "react-jsx", "module": "esnext", "target": "es2022", "moduleResolution": "bundler" } }' > tsconfig.json
docker exec react-lab npx tsx 04-transition.tsx # chạy từng file lab
docker rm -f react-lab # dọn
dom.tsx — dựng DOM giả, import đầu tiên ở mọi file lab:
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
Object.assign(globalThis, { window: dom.window, document: dom.window.document,
HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, Event: dom.window.Event, MouseEvent: dom.window.MouseEvent });
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = process.env.ACT === '1';
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
export const $ = (s: string) => document.querySelector(s) as HTMLElement;
export const click = (el: Element) => el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
04-transition.tsx (rút gọn phần chính):
const Item = ({ q, i }) => { const t = performance.now(); while (performance.now() - t < 1); return <li>{q}{i}</li>; };
const SlowList = memo(({ q }) => <ul>{Array.from({ length: 300 }, (_, i) => <Item key={i} q={q} i={i} />)}</ul>);
function App() {
const [text, setText] = useState(''); const [query, setQuery] = useState('');
// log thời điểm commit của từng state bằng useLayoutEffect([text]) / useLayoutEffect([query])
const onType = () => { setText(next); if (mode === 'transition') startTransition(() => setQuery(next)); else setQuery(next); };
return <><input value={text} readOnly /><button onClick={onType} /><SlowList q={query} /></>;
}
// Gõ 'a','ab','abc' cách nhau 40ms:
// discrete-* : click(btn) — event DOM thật → SyncLane
// default-from-timer: gọi setText() thẳng từ setTimeout → DefaultLane
Lưu ý khi tự chạy: lần đầu tôi gọi setText thẳng từ timer và thấy transition "không ngắt được" — đó chính
là luật DefaultLane ở mục 3.3, không phải lỗi lab. Muốn mô phỏng người dùng gõ phím phải phát event DOM thật.
Các file khác (cùng thư mục): 01-batching.tsx (đếm render khi gọi 3 setState ở event /
setTimeout / promise / flushSync), 02-key.tsx (key index vs id, component lồng),
r17/b17.mjs (cùng bài batching trên React 17 với ReactDOM.render).