Kỹ Thuật Chuyên Sâu • 32 phút đọc • 08/10/2026

React Core Bên Trong: Fiber, Lanes Và Scheduler — Từ setState Đến Pixel

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Cơ cấu bánh răng đồng hồ
Ảnh: Bettyblackstock · CC BY-SA 4.0

Bài này không dạy React. Bài này mở nắp capo.

Mình viết cho ba nhóm. Mid đọc để hiểu vì sao setState không chạy ngay, và vì sao đặt hook trong if lại hỏng. Senior đọc để biết khi nào một re-render là bắt buộc và khi nào là do mình tự gây ra. Lead đọc để có đủ từ vựng kỹ thuật khi phải quyết định giữa “thêm memo” và “sửa lại state model”.

Mọi tên hàm trong bài là tên thật trong mã nguồn React, thuộc packages/react-reconciler, packages/scheduler và packages/react-dom. Code minh hoạ đã rút gọn và ghi rõ là rút gọn, không phải bản gốc.

Bài viết dựa trên React 19. Kiến trúc Fiber + lanes + scheduler mà bài mô tả không đổi từ React 18 sang 19. React 19 thêm API ở tầng trên, không thay bộ máy bên dưới. Chỗ nào có rủi ro lệch giữa các minor version, mình ghi rõ.

1. Ba tầng, ba nhịp thời gian khác nhau

React không phải một khối. Nó là ba tầng, và mỗi tầng có khái niệm thời gian riêng.

flowchart TD
    A["Event handler<br/>onClick, useEffect, fetch resolve"] --> B["Reconciler<br/>tính toán cây mới<br/>KHÔNG chạm DOM"]
    B --> C["Scheduler<br/>quyết định khi nào chạy<br/>và chạy được bao lâu"]
    C --> B
    B --> D["Commit<br/>mutate DOM thật"]
    D --> E["Host: react-dom<br/>setAttribute, appendChild..."]
    E --> F["Browser<br/>style, layout, paint, composite"]
    F --> G["useEffect chạy<br/>SAU khi đã paint"]

    style B fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style C fill:#3b2f1e,stroke:#f59e0b,color:#fff
    style D fill:#3f1e2b,stroke:#fb7185,color:#fff

Ba tầng đó là:

  • Reconciler (react-reconciler) — biết cây component trông thế nào, và cây mới phải ra sao. Tầng này không biết DOM là gì.
  • Scheduler (scheduler) — biết cái gì gấp, cái gì không, và cắt việc dài thành lát ngắn.
  • Host (react-dom trong browser) — biết document.createElement là gì.

Việc tách tầng này chính là lý do react-native, react-dom/server và react-three-fiber dùng chung một reconciler. Đổi host là đổi tầng dưới cùng, không đụng hai tầng trên.

Một câu hỏi dùng để phân biệt người hiểu React và người thuộc API: render phase có được phép chạm DOM không? Không. Và không phải vì “React cấm” như một quy tắc đạo đức. Vì render phase có thể chạy nhiều lần, chạy lại sau khi bị ngắt, hoặc bị vứt bỏ hoàn toàn. Chạm DOM ở đó nghĩa là side effect có thể xảy ra nhiều lần hoặc xảy ra rồi không bao giờ được hoàn tác.

2. Fiber: đơn vị công việc

Fiber là một object JavaScript mô tả một node trong cây. Nhưng cách mô tả mới là phần đáng nói.

// Rút gọn từ ReactFiber.js — bỏ nhiều field không liên quan tới bài này
type Fiber = {
  tag: WorkTag;              // FunctionComponent, HostComponent, SuspenseComponent...
  type: any;                 // chính cái component/function đó
  stateNode: any;            // DOM node thật (với HostComponent) hoặc instance class

  return: Fiber | null;      // cha
  child: Fiber | null;       // con đầu tiên
  sibling: Fiber | null;     // anh em kế tiếp

  alternate: Fiber | null;   // bản đối chiếu — nền tảng của double buffering

  pendingProps: any;         // props của lần render này
  memoizedProps: any;        // props của lần render trước
  memoizedState: any;        // với function component: linked list của hooks
  updateQueue: any;

  flags: Flags;              // việc cần làm trên chính fiber này khi commit
  subtreeFlags: Flags;       // gộp của cả cây con — để biết có cần đi sâu không
  deletions: Fiber[] | null; // con bị xoá

  lanes: Lanes;              // update đang chờ trên chính nó
  childLanes: Lanes;         // update đang chờ ở đâu đó trong cây con
};

2.1. Vì sao là linked list, không phải cây đệ quy

Cách cũ (stack reconciler) duyệt cây bằng đệ quy. Đệ quy nghĩa là call stack của JavaScript giữ trạng thái duyệt, và call stack thì không tạm dừng rồi chạy lại được — bỏ giữa chừng là mất hết.

Fiber đổi sang con trỏ: cha, con đầu, anh em kế. Việc duyệt trở thành một vòng while với ba biến. Trạng thái nằm trong biến của mình, nên dừng ở giữa, trả quyền cho browser, rồi quay lại chạy tiếp là chuyện bình thường.

// Rút gọn từ ReactFiberWorkLoop.js
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

Một dòng while, hai điều kiện. Đó là toàn bộ cơ chế time slicing ở tầng reconciler — phần “chạy được bao lâu” do shouldYield() trả lời, và nó thuộc tầng Scheduler.

2.2. Double buffering: current và workInProgress

React giữ hai cây cùng lúc. Cây current là cây đang hiển thị trên màn hình. Cây workInProgress là cây đang được dựng.

Mỗi fiber trong cây mới có một con trỏ alternate trỏ về fiber tương ứng ở cây cũ. Khi commit xong, root.current được gán sang cây mới. Hai cây hoán đổi vai trò.

Cây fiber: con trỏ return/child/sibling, alternate và double buffering

Hai cây song song, nối với nhau bằng alternate. Commit xong thì root.current đổi hướng.

Nên render có thể bị vứt bỏ mà không để lại rác: mọi thứ render tạo ra đều nằm ở cây workInProgress, cây current chưa bị chạm tới. Vứt cây workInProgress đi là xong. Không cần rollback, không cần undo log.

2.3. flags và subtreeFlags

Fiber không mô tả “cây trông thế nào”. Nó mô tả “cần làm gì”. Mỗi fiber mang một bitmask flags ghi việc của chính nó, và subtreeFlags gộp việc của toàn bộ cây con.

Nhờ subtreeFlags, commit không phải duyệt hết cây. Gặp một fiber có subtreeFlags === NoFlags thì bỏ luôn cả nhánh — không có gì để làm ở dưới đó. Với app lớn, đây là khác biệt giữa duyệt 50.000 fiber và duyệt 200.

Tên các bit đổi qua các version (effectTag ở React 16, flags từ React 17, và các mask gộp như MutationMask, LayoutMask, PassiveMask sau này). Bản chất giữ nguyên: đây là một bảng công việc, không phải một mô tả trạng thái.

3. Lanes: hệ thống ưu tiên

Phần thay đổi nhiều nhất từ React 18, và cũng là phần ít người đọc kỹ nhất.

React cần trả lời một câu: trong 5 update đang chờ, làm cái nào trước? Trước React 17, câu trả lời dùng expirationTime — mỗi update một con số thời gian hết hạn. Cách đó có vấn đề: nó tuyến tính, không gộp nhóm được, và không biểu diễn nổi “hai update này phải chạy chung với nhau”.

Từ React 18, câu trả lời là lanes: một bitmask 31 bit. Mỗi bit là một lane. TotalLanes = 31.

LaneDùng cho
SyncLaneSự kiện rời rạc: click, keypress, flushSync
InputContinuousLaneTương tác liên tục: scroll, drag, mousemove
DefaultLanesetState thường, update không đến từ event
TransitionLanes16 lane riêng cho startTransition, useDeferredValue
RetryLanesThử lại sau khi Suspense bị treo
IdleLaneViệc có thể chờ
OffscreenLaneCây bị ẩn (<Activity>, prerender)

Bitmask 31 bit: mỗi lane một bit, gộp và tách bằng phép toán bit

Mỗi lane là một bit. Gộp nhiều update = phép |. Kiểm tra có việc gấp không = phép &.

3.1. Vì sao bitmask, không phải mảng

Vì phép toán bit là O(1) và không cấp phát bộ nhớ.

// Rút gọn
function mergeLanes(a: Lanes, b: Lanes): Lanes {
  return a | b;
}

function includesSomeLane(a: Lanes, b: Lanes): boolean {
  return (a & b) !== NoLanes;
}

function removeLanes(set: Lanes, subset: Lanes): Lanes {
  return set & ~subset;
}

Một fiber có thể đang chờ đồng thời một update SyncLane và một update transition — chỉ cần bật hai bit. Kiểm tra “có việc gấp không” trong 50.000 fiber là một phép & trên số nguyên, không phải vòng lặp qua mảng update.

Cái này nghe như chi tiết vặt. Nó không vặt. Chính chỗ này khiến việc “quét toàn cây để biết có gì gấp không” trở nên rẻ, và nhờ đó getNextLanes mới gọi được thường xuyên.

3.2. Từ event tới lane

Khi bạn gọi setState, React phải quyết định update đó thuộc lane nào. Câu trả lời phụ thuộc vào ngữ cảnh đang chạy, không phải vào bản thân setState.

flowchart TD
    A["setState / dispatchSetState"] --> B{"Đang ở trong ngữ cảnh nào?"}
    B -->|"discrete event handler"| C["SyncLane<br/>ưu tiên cao nhất"]
    B -->|"continuous event: scroll, drag"| D["InputContinuousLane"]
    B -->|"trong startTransition"| E["một trong TransitionLanes"]
    B -->|"trong useEffect / promise / timeout"| F["DefaultLane"]
    B -->|"useDeferredValue"| G["TransitionLane<br/>hạ cấp từ lane hiện tại"]
    C --> H["requestUpdateLane()"]
    D --> H
    E --> H
    F --> H
    G --> H
    H --> I["markRootUpdated(root, lane)<br/>root.pendingLanes |= lane"]
// Rút gọn từ ReactFiberWorkLoop.js
function requestUpdateLane(fiber: Fiber): Lane {
  const updateLane = requestUpdateLaneFromEventPriority();
  if (updateLane !== NoLane) return updateLane;

  // ...
  return DefaultLane;
}

Cùng một setState, đặt trong onClick và đặt trong setTimeout cho ra hai lane khác nhau. Đó là lý do “React gom update” nghe như một hành vi cố định, nhưng thực tế phụ thuộc hoàn toàn vào chỗ gọi.

3.3. pendingLanes, childLanes và bài toán tìm việc

Root giữ pendingLanes — toàn bộ lane đang chờ ở mọi nơi trong cây. Nhưng root cần biết đi sâu vào nhánh nào, nếu không thì mỗi update nhỏ cũng phải duyệt cả cây để tìm.

Đó là việc của childLanes trên fiber. Khi một update được đánh dấu, markUpdateLaneFromFiberToRoot đi ngược từ fiber đó lên root và, trên đường đi, bật bit tương ứng vào childLanes của từng tổ tiên.

Kết quả: beginWork chỉ cần so childLanes với lane của root. Không giao nhau thì bailout cả nhánh.

sequenceDiagram
    participant U as Update tại fiber lá
    participant P as Tổ tiên
    participant R as FiberRoot
    U->>P: markUpdateLaneFromFiberToRoot
    Note over P: bật bit vào childLanes<br/>của từng tổ tiên trên đường đi
    P->>R: tới root
    R->>R: pendingLanes |= lane
    R->>R: ensureRootIsScheduled(root)

3.4. Chống bỏ đói

Một nhánh SyncLane liên tục được bật sẽ chặn mãi một transition đang chờ. React xử lý bằng cách đánh dấu lane bị bỏ đói quá lâu là “hết hạn”, rồi getNextLanes sẽ kéo nó lên chạy trước — kể cả khi có việc gấp hơn vừa tới.

Cơ chế này (markStarvedLanesAsExpired và biến entangledLanes trong React 18 trở đi) là lý do trên thực tế bạn không thấy một startTransition bị treo vĩnh viễn khi người dùng đang gõ liên tục. Không có nó, “ưu tiên thấp” đồng nghĩa với “có thể không bao giờ chạy”.

4. Scheduler: cắt main thread thành lát 5ms

Reconciler biết làm gì. Scheduler biết khi nào dừng lại.

Browser chỉ có một main thread. Một while chạy 300ms để tính cây mới nghĩa là 300ms không ai xử lý được click, không scroll được, không repaint được. Scheduler tồn tại để chặn điều đó.

4.1. shouldYieldToHost

// Rút gọn từ Scheduler.js
const frameInterval = 5;

function shouldYieldToHost(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    // Chặn main thread chưa đủ lâu, chưa cần nhường
    return false;
  }
  // ...
  return true;
}

frameInterval = 5 mili giây. Cứ 5ms, reconciler phải tự hỏi “đã tới lúc nhường chưa” và nếu có thì thoát khỏi vòng lặp, trả quyền cho browser.

Năm mili giây chứ không phải mười sáu (một frame ở 60fps) là con số có chủ ý. Nó để chừa chỗ cho phần còn lại: nếu React dùng trọn 16ms thì chẳng còn gì cho style, layout và paint, và frame vẫn rớt.

4.2. Vì sao là MessageChannel, không phải setTimeout

Sau khi nhường, React cần được gọi lại ngay khi main thread rảnh. setTimeout(fn, 0) nghe hợp lý nhưng browser kẹp delay lồng nhau lên tối thiểu 4ms sau vài lần lặp, và còn bị giới hạn tần suất với tab ở nền. Nghĩa là mỗi lần nhường sẽ mất thêm 4ms chết.

MessageChannel không có giới hạn đó. Đẩy một message vào port2 sẽ khiến port1.onmessage chạy ở một macrotask sạch, ngay sau khi main thread rảnh, không qua bất kỳ ngưỡng clamp nào.

// Rút gọn từ Scheduler.js
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;

Chi tiết này bạn không cần biết để viết React. Nhưng nó giải thích một hiện tượng kỳ lạ: vì sao update transition lại chạy “mượt” mà không xuất hiện trong stack trace của setTimeout khi bạn profile.

4.3. ensureRootIsScheduled: sync hay concurrent

Sau mỗi update, React gọi ensureRootIsScheduled(root). Đây là chỗ quyết định update này có bị cắt lát hay không.

flowchart TD
    A["ensureRootIsScheduled(root)"] --> B["nextLanes = getNextLanes(root)"]
    B --> C{"nextLanes có<br/>SyncLane?"}
    C -->|Có| D["scheduleSyncCallback<br/>→ scheduleMicrotask<br/>chạy hết trong một tick, KHÔNG cắt lát"]
    C -->|Không| E["scheduleCallback(SchedulerPriority,<br/>performConcurrentWorkOnRoot)"]
    E --> F["workLoopConcurrent<br/>while (... && !shouldYield())"]
    F --> G{"Hết 5ms?"}
    G -->|Có| H["trả quyền cho browser<br/>hẹn qua MessageChannel"]
    G -->|Không| F
    H --> F

    style D fill:#3f1e2b,stroke:#fb7185,color:#fff
    style F fill:#1e3a5f,stroke:#38bdf8,color:#fff

Điều quan trọng ở đây: không phải mọi render đều bị cắt lát. Update trên SyncLane chạy trọn vẹn trong một tick, không nhường. Chỉ update trên lane thấp hơn mới đi qua đường concurrent và có thể bị ngắt.

Nên câu “React 18 render bất đồng bộ” là câu sai một nửa. Mặc định của setState trong event handler vẫn là chạy đồng bộ tới hết, sau đó mới tới commit. startTransition mới là thứ mở cửa cho việc bị ngắt.

5. Vòng lặp render: beginWork và completeWork

Có việc để làm thì reconciler chạy performUnitOfWork, và mỗi unit là một fiber. Ở đó có đúng hai hàm thay phiên nhau.

flowchart TD
    S["beginWork(fiber)"] --> T{"Có bailout được?"}
    T -->|Có| U["trả về null<br/>không đi sâu nhánh này"]
    T -->|Không| V{"fiber.tag là gì?"}
    V -->|FunctionComponent| W["renderWithHooks<br/>chạy component, thu được children"]
    V -->|HostComponent| X["updateHostComponent"]
    V -->|Khác| Y["updateXxx tương ứng"]
    W --> Z["reconcileChildren<br/>diff children với fiber con cũ"]
    X --> Z
    Y --> Z
    Z --> AA["trả về fiber con đầu tiên<br/>để đi xuống"]
    AA --> S
    U --> AB{"Còn sibling?"}
    AB -->|Có| S
    AB -->|Không| AC["completeUnitOfWork<br/>đi lên bằng completeWork"]
    AC --> AD["completeWork(fiber)<br/>tạo/cập nhật DOM instance,<br/>gộp flags lên subtreeFlags cha"]
    AD --> AB

    style W fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style AD fill:#3f1e2b,stroke:#fb7185,color:#fff
  • beginWork đi xuống. Với function component, nó gọi renderWithHooks — tức là chạy function của bạn và lấy JSX trả về. Sau đó reconcileChildren diff children mới với fiber con cũ.
  • completeWork đi lên. Nó tạo hoặc cập nhật DOM instance cho host component, và gộp flags của con lên subtreeFlags của cha.

completeWork là chỗ đáng chú ý. Nó không tạo DOM ngay vào cây thật — nó tạo instance và gắn vào fiber, để phần gắn vào document chỉ xảy ra ở commit. Nhờ vậy toàn bộ quá trình render vẫn không chạm vào cây DOM đang sống.

5.1. Bailout: ba điều kiện, không phải một

Bailout là khi React quyết định không đi sâu vào một nhánh. Điều kiện không chỉ là “props không đổi”.

// Rút gọn từ ReactFiberBeginWork.js
function beginWork(current: Fiber | null, workInProgress: Fiber, renderLanes: Lanes) {
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps === newProps && !includesSomeLane(renderLanes, workInProgress.type)) {
      // props y hệt (so sánh tham chiếu, không deep-equal) và fiber này không chờ update
      // -> thử bailout
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }
  // ...
}

Ba điều kiện để một nhánh được bỏ qua hoàn toàn:

  1. props bằng nhau theo tham chiếu — oldProps === newProps. Không phải deep equal. Đây là gốc rễ của cả một hệ quả về sau: tạo object/array mới mỗi lần render làm điều kiện này luôn sai.
  2. Fiber đó không có update đang chờ trên chính nó (workInProgress.lanes).
  3. Cả cây con cũng không có update đang chờ (childLanes không giao với renderLanes) — nếu có thì bailoutOnAlreadyFinishedWork vẫn đi xuống.

Điều kiện thứ ba là chỗ nhiều người hiểu sai. Bailout ở một component không có nghĩa là cả cây con của nó không render. Nó chỉ có nghĩa là component đó không render. Nếu một đứa cháu có update, React vẫn đi xuyên qua để tới chỗ đó.

5.2. Vì sao render phải pure

Giờ thì lý do đã rõ ràng, không cần tin vào lời dạy.

renderWithHooks có thể được gọi nhiều lần cho cùng một component trong cùng một lần cập nhật — mỗi lần bị ngắt hoặc bị vứt bỏ là một lần chạy lại. Kết quả của lần chạy trước có thể bị ném đi toàn bộ. Nếu function của bạn ghi vào một biến ngoài, gửi request, hoặc tăng counter trong module scope, thì số lần thực thi không xác định — và không có gì để rollback.

Với app nhỏ, render hầu như luôn chạy đúng một lần, nên bug kiểu này nằm im. Nó chỉ nổ khi có transition, Suspense hoặc StrictMode bật double-invoke.

6. Hooks nhìn từ trong

Hook không phải API ma thuật. Chúng là một linked list gắn trên fiber.

6.1. memoizedState là linked list

Với function component, fiber.memoizedState là con trỏ tới hook đầu tiên. Mỗi hook có một next trỏ sang hook kế.

Danh sách hook gắn trên fiber và vòng updateQueue

Mỗi hook là một node. queue.pending là circular list — con trỏ next của node cuối trỏ về node đầu.

// Rút gọn từ ReactFiberHooks.js
type Hook = {
  memoizedState: any;   // giá trị hiện tại của hook này
  baseState: any;       // state trước khi xử lý hết queue
  baseQueue: Update | null;
  queue: UpdateQueue | null;  // các update đang chờ
  next: Hook | null;
};

Đó là toàn bộ lý do “không gọi hook trong điều kiện”. React không có tên cho hook. Nó không biết useState thứ ba là cái nào. Nó biết thứ tự gọi. Lần render này hook thứ ba là gì là do lần render trước hook thứ ba là gì quyết định.

Đặt useState trong if làm lệch thứ tự giữa hai lần render. Từ đó trở đi, mọi hook đọc nhầm node, và React báo lỗi “Rendered fewer hooks than expected” — nếu may.

6.2. updateQueue là circular linked list

Chi tiết này ít người biết, và nó khá hay.

// Rút gọn từ ReactFiberHooks.js
type UpdateQueue<S, A> = {
  pending: Update<S, A> | null;  // trỏ tới update MỚI NHẤT, không phải cũ nhất
  lanes: Lanes;
  dispatch: ((action: A) => void) | null;
  lastRenderedReducer: ((s: S, a: A) => S) | null;
  lastRenderedState: S | null;
};

update.next của update mới nhất trỏ về update cũ nhất. Danh sách là vòng tròn.

Lợi ích: chỉ cần giữ một con trỏ thay vì hai (head và tail). Muốn duyệt từ đầu thì đi từ pending.next. Muốn thêm vào cuối thì chèn sau pending — đúng O(1), không cần tìm tail.

6.3. dispatchSetState và đường tắt eager state

Chỗ này trả lời câu hỏi rất phổ biến: “vì sao setState cùng giá trị cũ thì không re-render?”

// Rút gọn từ ReactFiberHooks.js
function dispatchSetState(fiber, queue, action) {
  const lane = requestUpdateLane(fiber);

  const update = { lane, action, hasEagerState: false, eagerState: null, next: null };

  if (isRenderPhaseUpdate(fiber)) {
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    const alternate = fiber.alternate;
    // Chỉ thử tính trước khi fiber chưa có update nào đang chờ
    if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
      const lastRenderedReducer = queue.lastRenderedReducer;
      const currentState = queue.lastRenderedState;
      const eagerState = lastRenderedReducer(currentState, action);

      update.hasEagerState = true;
      update.eagerState = eagerState;

      if (is(eagerState, currentState)) {
        // Y hệt giá trị hiện tại -> không cần schedule gì cả
        return;
      }
    }
    // ...
    scheduleUpdateOnFiber(fiber, lane);
  }
}

Ba điều đáng chú ý ở đây:

  1. React thực sự gọi hàm reducer của bạn ngay tại chỗ, trước khi schedule. Với useState, reducer chính là “trả về giá trị mới”. Đó là lý do truyền hàm vào setState (setX(prev => prev + 1)) lại quan trọng hơn là một mẹo style.
  2. Đường tắt chỉ mở khi fiber chưa có update nào đang chờ. Nếu đã có update xếp hàng, React không tính trước được vì kết quả phụ thuộc vào thứ tự các update trước đó.
  3. So sánh bằng Object.is, không phải ===. Khác nhau đúng ở NaN và +0/-0.

Hệ quả thực tế: setState với một object mới luôn khác tham chiếu, nên luôn re-render — dù nội dung y hệt. Đây là gốc của rất nhiều “vì sao component này render hoài”.

6.4. Bailout ở tầng hooks

Nếu đường tắt eager state không mở, React vẫn còn một lớp nữa: khi render lại, updateReducer phát hiện ra update này đã có eagerState bằng state hiện tại và lane không đổi, thì giữ nguyên hook, và bailoutHooks đánh dấu fiber không cần render lại.

Nghĩa là có hai lớp chặn re-render vô ích: một ở lúc dispatch, một ở lúc render. Nếu bạn thấy component vẫn re-render sau khi setState cùng giá trị, thường là vì:

  • Giá trị mới khác tham chiếu (object/array/function tạo mới)
  • Có update khác đang chờ trên cùng fiber
  • Component cha render và props là object mới

Chỉ trường hợp thứ ba mới cần memo. Hai trường hợp đầu là bug state model, không phải vấn đề performance.

7. Commit: ba pha, ba mức độ rủi ro

Render phase có thể chạy nhiều lần. Commit phase chạy đúng một lần, và không được phép thất bại giữa chừng.

flowchart TD
    R["renderRootSync / renderRootConcurrent<br/>cây workInProgress đã xong"] --> A1
    subgraph C["commitRootImpl — CHẠY MỘT LẦN, KHÔNG CẮT LÁT"]
      direction TB
      A1["1. commitBeforeMutationEffects<br/>getSnapshotBeforeUpdate<br/>schedule useEffect (passive)"] --> A2
      A2["2. commitMutationEffects<br/>chèn/xoá/sửa DOM thật<br/>detach ref cũ<br/>cleanup useLayoutEffect"] --> A3
      A3["3. commitLayoutEffects<br/>attach ref mới<br/>useLayoutEffect chạy<br/>componentDidMount/Update"]
    end
    A3 --> SW["root.current = finishedWork<br/>hoán đổi current ← workInProgress"]
    SW --> P["Browser paint"]
    P --> PE["flushPassiveEffects<br/>useEffect chạy SAU paint"]

    style C fill:#2a1e3f,stroke:#a78bfa,color:#fff
    style P fill:#1e3f2a,stroke:#34d399,color:#fff

Đường thời gian commit: ba pha đồng bộ rồi mới tới paint và useEffect

Ba pha commit chạy liền một mạch, không nhường. Cửa sổ duy nhất để React ngắt là trước khi vào commit.

7.1. Điểm không thể quay lại

Toàn bộ commit chạy đồng bộ. Không có shouldYield() nào trong đó. Một khi đã vào commitMutationEffects thì React phải đi hết, vì cây DOM thật đang ở trạng thái nửa vời giữa hai phiên bản.

Nên khi bạn đo và thấy 40ms nằm trong commit, đó không phải thứ bị cắt lát. Đó là 40ms mất trắng cho mọi tương tác. Time slicing cứu được render phase, không cứu được commit.

Nên “tối ưu React” theo nghĩa sâu nhất không phải là làm render nhanh hơn, mà là làm cho mỗi lần commit nhỏ hơn. Chia nhỏ state, tách component, đẩy việc nặng ra khỏi render — tất cả đều nhằm giảm số DOM node phải sửa trong một lần commit.

7.2. Thứ tự effect: con trước cha

Cả layout effect lẫn passive effect đều chạy từ con lên cha. Đây là điều ngược với trực giác.

sequenceDiagram
    participant P as Cha
    participant C as Con
    participant GC as Cháu
    Note over P,GC: MOUNT — con trước, cha sau
    GC->>GC: useLayoutEffect (con)
    C->>C: useLayoutEffect
    P->>P: useLayoutEffect (cha)
    Note over P,GC: SAU PAINT
    GC->>GC: useEffect (cháu)
    C->>C: useEffect
    P->>P: useEffect (cha)
    Note over P,GC: UNMOUNT — ngược lại, cha trước
    P->>P: cleanup (cha)
    C->>C: cleanup
    GC->>GC: cleanup (cháu)

Lý do con chạy trước khi mount: khi effect của cha chạy, nó có thể cần con đã sẵn sàng. Ví dụ cha đo tổng chiều cao các con. Đó cũng là lý do bạn không thể dựa vào ref của con trong useLayoutEffect của cha nếu con mount sau.

Khi unmount thì ngược lại — cleanup chạy từ cha xuống. Nhất quán với thứ tự huỷ resource: huỷ cái ngoài trước.

7.3. Vì sao useEffect chạy sau paint còn useLayoutEffect thì không

useLayoutEffect nằm trong commitLayoutEffects, tức là trước khi browser vẽ frame. Nó chạy đồng bộ và chặn paint.

useEffect được schedule trong commitBeforeMutationEffects và chỉ chạy ở flushPassiveEffects, sau khi frame đã được vẽ. Nó không chặn paint.

// Rút gọn
// Dùng useLayoutEffect khi: cần đo DOM trước khi user thấy
useLayoutEffect(() => {
  const height = ref.current.getBoundingClientRect().height;
  setHeight(height); // re-render trước khi paint -> không thấy nhấp nháy
}, []);

// Dùng useEffect khi: không cần chặn paint
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

Nếu useLayoutEffect chứa việc nặng, bạn đang chặn paint — và không có time slicing nào cứu được, vì đây là commit. Nếu useEffect chứa việc phải hoàn tất trước khi người dùng thấy gì đó, bạn sẽ thấy nhấp nháy.

8. Concurrent: ngắt, vứt, và tearing

Tới đây thì mọi mảnh đã ghép lại được.

8.1. Khi nào React vứt kết quả render

React vứt cây workInProgress đang dựng khi một lane ưu tiên cao hơn tới giữa chừng. Không cần hoàn tác vì cây current chưa bị chạm.

stateDiagram-v2
    [*] --> RenderSync
    RenderSync --> Computing: beginWork / completeWork
    Computing --> Committing: xong, không bị ngắt
    Committing --> Painted: DOM mutate + paint
    Painted --> [*]

    Computing --> Interrupted: SyncLane mới tới
    Interrupted --> Computing: bắt đầu lại từ root với lane cao hơn
    Interrupted --> Discarded: lane cũ không còn ai chờ
    Discarded --> [*]

    note right of Interrupted
        Cây workInProgress bị bỏ
        Cây current chưa hề bị chạm
        -> không cần rollback
    end note

Cái giá phải trả: một cây bị vứt nghĩa là công đã bỏ ra để dựng nó cũng bị vứt. Nếu transition của bạn luôn bị ngắt, bạn đang render nhiều lần hơn chứ không ít hơn. Time slicing không miễn phí — nó mua độ nhạy của UI bằng CPU.

8.2. Tearing và useSyncExternalStore

Có một loại bug mà concurrent rendering tạo ra và chỉ concurrent mới có.

Giả sử bạn đọc một biến ngoài React (ví dụ một store tự viết) ở hai component. Nếu render bị ngắt giữa hai component đó, và store đổi giá trị trong lúc bị ngắt, thì component thứ nhất render với giá trị cũ còn component thứ hai với giá trị mới. Cùng một lần commit, hai phiên bản dữ liệu khác nhau trên màn hình. Đó là tearing.

sequenceDiagram
    participant R as Reconciler
    participant A as Component A
    participant S as External store
    participant B as Component B
    R->>A: render (đọc store = 1)
    Note over R: BỊ NGẮT — nhường main thread
    S->>S: store cập nhật thành 2
    R->>B: render (đọc store = 2)
    Note over A,B: A hiển thị 1, B hiển thị 2 — cùng một frame -> TEARING

React chặn việc này bằng useSyncExternalStore. Hook này không chỉ subscribe. Nó kiểm tra lại giá trị ngay trước khi commit:

// Rút gọn từ useSyncExternalStore
// Nếu snapshot đã đổi từ lúc bắt đầu render -> render lại đồng bộ trên SyncLane
function handleStoreChange() {
  if (checkIfSnapshotChanged(inst)) {
    forceStoreRerender(fiber);
  }
}

Update đó bị ép xuống SyncLane. Nghĩa là React tự nguyện từ bỏ time slicing cho nhánh này để đảm bảo tính nhất quán. Đánh đổi có chủ ý, và cũng là lý do bạn không nên dùng useSyncExternalStore cho state thay đổi liên tục ở tần suất cao.

8.3. startTransition: hạ cấp lane, không phải hoãn việc

// Không phải "chạy sau", mà là "chạy ở lane thấp hơn, có thể bị ngắt"
startTransition(() => {
  setSearchQuery(input);       // DefaultLane -> bị hạ xuống TransitionLane
  setFilteredResults(compute()); // cùng lane đó
});

startTransition không đẩy việc vào hàng đợi. Nó đổi lane của update. Update đó vẫn được schedule ngay, vẫn bắt đầu render ngay — chỉ khác là giờ nó có thể bị ngắt nếu có việc gấp hơn.

Đó là lý do startTransition không cứu được một render đắt đỏ vốn dĩ phải chạy: nếu không có việc gì gấp chen vào, transition vẫn chạy y hệt, tốn đúng từng đó thời gian.

9. Đo và debug: đúng công cụ cho đúng tầng

Câu hỏiCông cụTầng
Component nào render, render mấy lần, vì saoReact DevTools ProfilerReconciler
Render hay commit mới là chỗ tốn thời gianProfiler, xem rank của commitReconciler + Host
Main thread bị chặn ở đâuChrome Performance, track MainTất cả
Layout thrashing, forced reflowPerformance, xem Recalculate Style / Layout lặpBrowser
Tearing, store không nhất quánLog giá trị snapshot kèm frameConcurrent
Update nào chạy lane nàoReact DevTools “Highlight updates” + Performance trackScheduler

Ba câu hỏi chẩn đoán mình dùng khi có người hỏi “app em chậm”:

  1. Render nhiều hay render đắt? Nếu nhiều — sửa state model. Nếu đắt — sửa thuật toán trong render.
  2. Thời gian nằm ở render hay commit? Nếu ở commit — time slicing không giúp gì, phải giảm số DOM node sửa.
  3. Component này render vì lý do gì? Props đổi tham chiếu, state nội bộ, context, hay cha render? DevTools trả lời được câu này, và câu trả lời quyết định có nên memo hay không.

10. Điều mình rút ra khi làm với những hệ thống lớn

Mấy thứ mình tin, xếp theo mức độ chắc chắn giảm dần.

  • Bailout theo tham chiếu là gốc rễ của phần lớn vấn đề hiệu năng React. Không phải memo thiếu, mà là object/array/function được tạo mới mỗi render. Sửa nguồn thì không phải rải memo ở 40 chỗ.
  • Lanes giải thích những thứ mà “batching” không giải thích được. Vì sao cùng một setState lại có hành vi khác nhau ở hai chỗ gọi. Vì sao transition đôi khi chạy ngay. Vì sao flushSync có tác dụng.
  • Time slicing chỉ nằm ở render phase. Nếu ai đó nói “React 18 làm app mượt hơn”, hãy hỏi: mượt hơn ở phần nào. Commit vẫn đồng bộ, vẫn chặn paint, và đó thường mới là chỗ 40ms nằm.
  • useSyncExternalStore là hợp đồng, không phải tiện ích. Nó không “tối ưu” việc đọc store. Nó trả giá bằng time slicing để mua tính nhất quán. Dùng sai chỗ sẽ làm app tệ hơn.
  • Không có tầng nào trong ba tầng này thay thế được tầng khác. Scheduler không cứu một render đắt. memo không cứu một commit nặng. Đo trước, rồi mới biết đang ở tầng nào.

Có một chỗ mình vẫn chưa chắc: ranh giới thực tế giữa InputContinuousLane và TransitionLane trong các app có cả scroll ảo hoá lẫn transition phức tạp. Trên lý thuyết thì rõ ràng. Trên thực tế, mình đã gặp trường hợp scroll ảo hoá làm transition của chính nó gần như không bao giờ hoàn tất, và cách xử lý cuối cùng là bỏ hẳn transition chứ không phải chỉnh lane. Nếu bạn có kinh nghiệm ngược lại, mình muốn nghe.

React 19 đổi gì ở tầng này

Câu hỏi mình nhận nhiều nhất sau khi viết bài: “React 19 có làm mấy thứ trên lỗi thời không?” Không. Nhưng có vài chỗ đáng nối vào.

Không đổi. Fiber vẫn là reconciler lõi, vẫn hai cây current/workInProgress nối bằng alternate. Lanes vẫn 31 bit với đúng bộ lane đó. Scheduler vẫn MessageChannel với frameInterval = 5. Ba pha commit vẫn đúng thứ tự đó. React 19 không viết lại bộ máy — nếu có, mọi thư viện bên thứ ba chạm vào internals sẽ vỡ cùng lúc.

Đổi ở tầng API, và có liên quan trực tiếp tới bài này:

  • ref thành prop thường. Không cần forwardRef nữa (nó bị deprecate, chưa xoá). Liên quan tới mục 7: ref vẫn được attach ở pha layout như cũ, chỉ khác cách truyền xuống.
  • findDOMNode bị xoá hẳn. Cách duy nhất còn lại để chạm DOM node là ref. Codebase nào còn dựa vào findDOMNode là chỗ phải sửa trước khi lên 19.
  • propTypes và defaultProps trên function component bị xoá. defaultProps không còn được đọc, nên giá trị mặc định phải chuyển sang default parameter. Việc này chạm vào pendingProps mà beginWork đem so sánh — prop thiếu giờ là undefined thay vì giá trị mặc định.
  • <Activity> (19.2) là API công khai cho hướng Offscreen. Đúng cái OffscreenLane trong bảng ở mục 3 phục vụ: ẩn một nhánh khỏi màn hình nhưng giữ state và cho nó ưu tiên thấp.
  • useEffectEvent (19.2) tách phần “phản ứng” khỏi phần “không phản ứng” của effect. Chỗ nối trực tiếp vào mục 7.3: dependency array vẫn là cách React biết khi nào chạy lại effect, còn logic đọc giá trị mới nhất mà không muốn nằm trong deps thì giờ có API chính thức thay vì mẹo qua useRef.
  • use cho phép suspend ngay trong render. Nó đi qua đúng các retry lane đã có, không thêm cơ chế mới.

Một thứ đáng để ý hơn cả: React Compiler. Bản 1.0 đã ổn định, tương thích ngược về React 17. Nó tấn công thẳng vào chỗ mình gọi là gốc rễ ở mục 10 — bailout theo tham chiếu. Compiler ổn định hoá tham chiếu bên trong component, nên props truyền xuống ít bị tạo mới mỗi lần render, và điều kiện oldProps === newProps trong beginWork đúng thường xuyên hơn. Nó không thay memo, và không thay Fiber — component đã compile vẫn chạy qua Fiber như thường. Nhưng nó làm bớt hẳn một nhóm bug trước đây phải sửa bằng tay.

Nguồn tham chiếu

  • Tên hàm và field lấy từ mã nguồn React 19: packages/react-reconciler/src/ReactFiberWorkLoop.js, ReactFiberBeginWork.js, ReactFiberCompleteWork.js, ReactFiberHooks.js, ReactFiberLane.js; packages/scheduler/src/forks/Scheduler.js.
  • frameInterval = 5 và việc scheduler dùng MessageChannel đã đối chiếu trực tiếp với mã nguồn tại thời điểm viết, không suy từ blog khác.
  • Giá trị bit cụ thể của từng lane mình cố tình không nêu. Chúng đã dịch giữa các version — việc thêm SyncHydrationLane làm lệch index của toàn bộ lane phía sau. Đọc theo tên lane ổn định hơn đọc theo số.
  • Các đoạn code trong bài là rút gọn, không phải bản gốc: đã bỏ nhánh dev-only, warning và một số tham số. Đừng copy vào sản phẩm.
  • Giới hạn quan trọng: đây là internals, không phải API công khai. “API công khai” ở đây nghĩa là bề mặt React cam kết giữ ổn định theo semver — useState, createRoot, useEffect. Tên file, tên hàm trong reconciler và cấu trúc fiber thì không nằm trong cam kết đó, và React nói thẳng điều này trong chính sách versioning của mình: “If you access internal property names like __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED … there is no warranty. You are on your own.” Cái tên hằng số đó là do chính React đặt, không phải mình thêm vào cho kịch tính.
  • Hệ quả thực tế: mọi thứ trong bài đúng với React 19, nhưng đừng viết code hay thư viện dựa lên chúng. Dùng để hiểu vì sao hành vi ở tầng API lại như vậy — hết.
  • Muốn đối chiếu: trong project React của bạn, mở node_modules/react-dom/cjs/react-dom.development.js — bản dev chưa minify nên đọc được tên hàm; hoặc đọc nguồn trên GitHub. Đó luôn là ground truth cuối cùng.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.