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.
commitPha áp các thay đổi đã tính được vào DOM thật. Chạy một mạch, không bị ngắt.
reconciliationSo 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á.
FiberMộ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.
laneMức ưu tiên của một update, lưu dạng bitmask (dãy bit). Click = SyncLane, transition = TransitionLane...
batchingGộp nhiều setState thành một lần render.
concurrent renderingReact 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.
bailoutReact 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

"setState khô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, startTransition thì TransitionLane. React gộp các update trong cùng tick (từ React 18 gộp cả trong setTimeout và 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ác type thì huỷ cả nhánh, danh sách thì so theo key. 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ạy useLayoutEffect trước khi browser vẽ, useEffect sau. 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, setState từ 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ạyVỡ ở đâu
jQuery / DOM thủ côngNghe 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:

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 choKhô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ễ đọcHiệ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ìnhAnimation 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ườiTeam 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ăngVì sao nó phải tồn tại
Render phải pure, không side effectPha 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 ở devCá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áchKhi so cây, React cần biết "phần tử này là ai" để giữ đúng state cho đúng item.
State phải immutableBailout 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 HooksState 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ự.
useTransitionVì 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 CompilerMặ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:

  1. 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.
  2. Trong danh sách, key là 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 updateLaneCó time-slice (nhả luồng)?
flushSync, click, keydown, input (event rời rạc)SyncLaneKhông — render một mạch
Scroll, mousemove (event liên tục)InputContinuousLaneKhông
setState từ setTimeout, promise, fetch xongDefaultLane (giá trị 32)Không
startTransition, useDeferredValueTransitionLanesCó

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:

4. Đảm bảo và giới hạn

React đảm bảoReact 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ấtTransition 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 chaStore ngoài React được đọc nhất quán — cần useSyncExternalStore (xem bài state)

Giới hạn cứng:

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.

Timeline gõ 3 phím: không transition và có transition Hai hàng thời gian. Hàng trên không có transition: mỗi phím phải chờ cả khối render danh sách 400ms chạy xong mới hiện, ba khối nối nhau, chữ trễ tới gần 1 giây. Hàng dưới có transition: mỗi phím hiện gần như ngay (vạch xanh). Việc render danh sách bị chia thành lát nhỏ 5ms; khi phím mới tới, lát đang dở của "a" và "ab" bị vứt (gạch chéo), chỉ bản "abc" được render hết rồi commit. Không transition render "a" render "ab" render "abc" gõ a·ab·abc phím hiện ở cuối mỗi khối → trễ 400–980ms Có startTransition a ab abc commit danh sách "abc" vạch xanh = ô input commit ngay (2–5ms) · lát xanh dương = render 5ms rồi nhả luồng gạch đỏ = phần render "a", "ab" bị vứt vì có bản mới hơn
Hàng trên: mỗi phím chờ cả khối render chạy xong. Hàng dưới: ô input commit ngay; render danh sách chia lát 5ms, bản "a" và "ab" bị bỏ dở, chỉ "abc" được render đến cùng. Tổng CPU cho danh sách không giảm — chỉ đổi thứ tự.

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.

key bằng index: state ở lại vị trí, dữ liệu trượt lên Bên trái key bằng index: ba dòng An, Binh, Cuong mang key 0, 1, 2 và ghi chú riêng. Khi xoá An, tên Binh và Cuong trượt lên key 0 và 1, nhưng ghi chú gắn với key 0 và 1 vẫn đứng yên, nên Binh mang ghi chú của An. Bên phải key bằng id: ghi chú đi cùng tên nên luôn đúng. key = index key = id key 0key 1key 2 An BinhCuong 📝 ghi chú An 📝 ghi chú Binh 📝 ghi chú Cuong key AnAn📝 ghi chú An key BinhBinh📝 ghi chú Binh key CuongCuong📝 ghi chú Cuong Xoá An: bên trái tên trượt lên nhưng state đứng yên theo key 0/1 → Binh mang ghi chú của An.
State thuộc về key, không thuộc về dữ liệu bạn hiển thị. Key = index nghĩa là state thuộc về "vị trí thứ mấy".

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ứngCô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ừaReact DevTools Profiler → bật "Record why each component rendered".
State sai dòngKhô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

MetricNgưỡngLiên quan cơ chế render ở đâu
INP (p75)< 200ms tốt, > 500ms kémRender đồng bộ quá lâu sau click/gõ phím
Long task> 50ms là long taskMột lần render/commit lớn
JS bundle sizeTheo 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)0Xem 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

Upgrade / migration

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 19Vue 3Svelte 5SolidJSAngular 20+Livewire / Inertia (Laravel)
Cơ chế cập nhậtRe-render nhánh + diffReactivity proxy + VDOM (Vapor mode bỏ VDOM)Compiler + signals (runes), không VDOMSignals, cập nhật đúng nodeSignals + zonelessServer render, gửi HTML/props
Hiệu năng thôTrung bình (cần memo/compiler)TốtRất tốtRất tốtTốtPhụ thuộc round-trip mạng
Độ phức tạp mô hìnhTrung bình–cao (closure, effect, deps)Thấp–trung bìnhThấpTrung bình (dễ nhầm như React)CaoThấp cho dev PHP
Hệ sinh tháiLớn nhấtLớnVừaNhỏLớn, "có sẵn pin"Gắn với Laravel
Tuyển người (VN)Dễ nhấtDễKhóRất khóVừa (doanh nghiệp)Dev Laravel làm được
Độ trưởng thànhCao, có FoundationCaoVừaVừaCao, GoogleCao 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 ADRNội dung
Bối cảnhSản phẩm có N màn tương tác dày, team M dev frontend, backend Laravel.
Quyết địnhDù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ựcTuyển dễ; component tái dùng; tách team FE/BE qua API contract.
Hệ quả tiêu cựcThê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ạnReact 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 strategyGiữ 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ế

  1. 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.
  2. Đị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.
  3. Đẩ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).
  4. Tin rằng useTransition là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).
  5. 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.
  6. Đọc state ngay sau setState rồi dùng giá trị đó — luôn là giá trị cũ.
  7. Memo mọi thứ "cho chắc": mỗi memo tố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: MemoObj vẫ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).