Hooks sâu — closure, dependency, Strict Mode, React Compiler

Hooks trông đơn giản: useState, useEffect, xong. Nhưng phần lớn bug React thật trong production nằm ở hooks: giá trị cũ bị "đóng băng" trong closure, effect chạy vô hạn, gọi API gấp đôi. Tháng 9/2025, một useEffect sai dependency đã góp phần đánh sập dashboard và API của Cloudflare. Bài này giải thích cơ chế để bạn tự suy ra đúng/sai, và xem React Compiler 1.0 thực sự làm được gì.

Đã verify gì. Chạy thật trên React 19.3.0, react-dom 19.3.0, Node 22.23.3, jsdom 26.1 (container tạm). React Compiler: babel-plugin-react-compiler 1.0.0 với @babel/core 8.0.6 — compile thật rồi chạy thật, đếm render. So sánh dev/production bằng biến NODE_ENV. Sự cố Cloudflare lấy từ bài postmortem công khai của họ — không tái hiện được hệ thống của họ, chỉ tái hiện đúng lỗi useEffect. Lệnh ở phụ lục.

Bảng thuật ngữ

Thuật ngữNghĩa ngắn
closureHàm "nhớ" các biến ở nơi nó được tạo ra, kể cả khi chạy ở chỗ khác, lúc khác.
stale closure (closure cũ)Closure nhớ giá trị của một lần render cũ, trong khi state đã đổi.
dependency array (deps)Mảng thứ hai của useEffect/useMemo/useCallback. React so từng phần tử bằng Object.is để quyết định chạy lại hay không.
referential equalitySo "có cùng một object trong bộ nhớ không", không so nội dung. {a:1} !== {a:1}.
Strict ModeChế độ chỉ có ở dev: render 2 lần, mount → unmount → mount lại để lộ code không pure.
idempotentChạy 1 lần hay nhiều lần kết quả như nhau.
React CompilerPlugin build-time tự chèn memo cho component và hook. Bản 1.0 ra 10/2025.
reactive scopeMột đoạn code mà compiler gom lại để cache chung, tính lại khi một trong các input đổi.
Effect Event (useEffectEvent)Hàm dùng trong effect, luôn đọc props/state mới nhất nhưng không làm effect chạy lại.

Câu trả lời 60 giây

"Hook là các ô nhớ gắn vào Fiber của component, xếp thành danh sách theo thứ tự gọi — nên không được gọi hook trong if. Mỗi lần render là một snapshot: props, state và mọi hàm tạo trong lần render đó đều thấy giá trị của snapshot ấy. Hàm nào sống lâu hơn một render — interval, subscription, promise — sẽ thấy giá trị cũ nếu không khai đủ dependency hoặc không dùng updater function. useEffect không phải lifecycle mà là đồng bộ với hệ thống bên ngoài; deps so bằng Object.is nên object tạo mới mỗi render làm effect chạy mỗi render — chính lỗi đã góp phần gây sự cố Cloudflare 9/2025. Strict Mode ở dev cố tình mount-unmount-mount để bắt effect thiếu cleanup. React Compiler tự memo, nhưng nó im lặng bỏ qua code sai luật và phụ thuộc hình dạng code — phải đo, không tin mù."

1. Bài toán gốc và bối cảnh ra đời

Trước 2019, component có state phải viết bằng class. Ba vấn đề lớn:

Vấn đề với classVí dụ
Logic liên quan bị chia theo lifecycle, logic không liên quan bị gộp chungSubscribe ở componentDidMount, cập nhật ở componentDidUpdate, huỷ ở componentWillUnmount — ba nơi cho một việc. Còn componentDidMount thì chứa cả subscribe, cả gọi API, cả đo DOM.
Không có cách tái dùng logic có state gọn gàngPhải dùng HOC (higher-order component — hàm bọc component) hoặc render props → "wrapper hell", DevTools thấy 10 lớp bọc.
this và bindthis.handleClick = this.handleClick.bind(this); this.props đọc lúc chạy nên có thể là props mới (bug ngược với stale closure).

Hooks ra mắt ở React Conf 10/2018, phát hành trong React 16.8 (2/2019). Ý tưởng: state và effect là những "ô nhớ" mà hàm component xin từ React, và gom logic theo mục đích vào custom hook (useOnlineStatus, useCart) thay vì theo lifecycle.

Hooks tối ưu choHooks KHÔNG tối ưu cho
Tái dùng logic có state qua custom hookMô hình tư duy dễ hiểu: closure + deps là nguồn bug số 1
Gom code theo mục đích, dễ tách, dễ testHiệu năng mặc định: mọi hàm/object tạo mới mỗi render → cần memo tay (mới có Compiler gỡ bớt)
Hợp với concurrent rendering (không có this đổi giữa chừng)Logic chạy dài theo thời gian (timer, socket) — phải cẩn thận closure

2. Mô hình tư duy cốt lõi

Hai ý tưởng, mọi thứ khác suy ra từ đây:

  1. Mỗi render là một snapshot. Trong một lần render, count là một hằng số. Handler, effect, callback tạo trong lần render đó "chụp" hằng số ấy. Không có biến nào tự cập nhật.
  2. Hook là ô nhớ đánh số theo thứ tự gọi. React không biết tên biến của bạn. Nó chỉ biết "hook thứ 1 là useState, hook thứ 2 là useEffect...".
flowchart LR
  subgraph R1["Render #1 (count = 0)"]
    H1["handleClick nhớ count=0"]
    E1["effect #1 nhớ count=0"]
  end
  subgraph R2["Render #2 (count = 1)"]
    H2["handleClick nhớ count=1"]
    E2["effect #2 nhớ count=1"]
  end
  R1 -- "setCount(1)" --> R2
  F["Fiber.memoizedState
hook1: state=1 → hook2: effect → hook3: ref"] -.-> R2

Hệ quả suy ra được:

3. Internals và quyết định thiết kế

3.1 Hooks là danh sách liên kết trên Fiber

fiber.memoizedState trỏ tới hook đầu tiên; mỗi hook có next. Khi render, React dùng một "dispatcher" khác nhau cho lần mount (tạo hook mới) và lần update (đi theo danh sách cũ):

// Mô phỏng rút gọn — không phải code thật của React
hook = { memoizedState, queue, next }
// useState(init)    : memoizedState = state; queue = vòng update chờ xử lý
// useReducer        : useState thật ra là useReducer với reducer (s, a) => typeof a === 'function' ? a(s) : a
// useRef(init)      : memoizedState = { current: init }  → cùng object mãi mãi
// useMemo(fn, deps) : memoizedState = [value, deps]      → deps khác thì gọi fn lại
// useEffect(fn,deps): đẩy effect vào danh sách của Fiber, gắn cờ HasEffect nếu deps khác

Đánh đổi: gọi theo thứ tự thì không cần tên/key cho từng hook → API cực gọn, custom hook ghép tự do. Cái giá là luật "không gọi có điều kiện" — phải có lint để giữ. Lab khi vi phạm:

[5] hook trong if, đổi on=false → true:
   React has detected a change in the order of Hooks called by %s. This will lead to bugs and errors if not fixed. ...
   onUncaughtError: Rendered more hooks than during the previous render.

3.2 So sánh deps bằng Object.is, từng phần tử

React không so sâu (deep compare) vì tốn và không chắc đúng (object có thể chứa hàm, vòng lặp tham chiếu). Nó so tham chiếu từng phần tử. Hệ quả: object/array/hàm tạo trong render luôn "khác".

[3] deps = { page: 1 } (object mới mỗi render) → effect chạy 6 lần cho 6 lần render
[3] deps = useMemo(() => ({page:1})) → effect chạy 1 lần cho 6 lần render

3.3 Thứ tự chạy và Strict Mode

Lab ghi lại thứ tự thật khi mount:

[2] mount KHÔNG StrictMode:        [2] mount CÓ StrictMode (dev):
   render A                           render A
   layoutEffect A                     render A                 ← render lần 2 để bắt code không pure
   effect A                           layoutEffect A
                                      effect A
                                      cleanup layoutEffect A   ← giả lập unmount
                                      cleanup effect A
                                      layoutEffect A           ← mount lại với state cũ
                                      effect A

Cùng code, NODE_ENV=production, CÓ StrictMode:
   render A
   layoutEffect A
   effect A                           ← production không chạy thêm gì
Strict Mode ở dev: mount, unmount giả, mount lại Ba bước sáng lần lượt. Bước 1: mount, effect chạy, mở kết nối chat. Bước 2: React giả lập unmount, cleanup chạy, đóng kết nối. Bước 3: mount lại với state cũ, effect chạy lần nữa. Nếu effect không có cleanup, bước 3 để lại hai kết nối cùng lúc — đó là bug mà Strict Mode muốn bạn thấy ở dev trước khi người dùng gặp ở production (vd khi quay lại trang, Fast Refresh, hoặc Activity ẩn/hiện). 1. mount effect: connect() kết nối: 1 2. unmount giả cleanup: disconnect() kết nối: 0 3. mount lại effect: connect() kết nối: 1 ✓ Thiếu cleanup → bước 2 không đóng gì → bước 3 để lại 2 kết nối, 2 listener, 2 lần POST analytics. Production không chạy bước 2–3, nhưng người dùng vẫn gặp đúng kịch bản này khi rời trang rồi quay lại.
Strict Mode không tạo ra bug — nó lộ bug sớm. Effect đúng là effect mà "setup → cleanup → setup" cho kết quả giống "setup" một lần.

3.4 useEffectEvent: tách "đọc giá trị mới nhất" khỏi "khi nào chạy lại"

Bài toán: effect kết nối chat theo roomId, khi có tin nhắn thì hiện thông báo theo theme. Đưa theme vào deps → đổi theme là ngắt kết nối rồi nối lại. Bỏ ra → lint báo, và thông báo dùng theme cũ. useEffectEvent (ổn định từ React 19.2; lab xác nhận typeof React.useEffectEvent === 'function' trên 19.3.0) tạo hàm luôn đọc giá trị mới nhất mà không phải khai vào deps:

const onMessage = useEffectEvent(msg => showToast(msg, theme)); // luôn thấy theme mới nhất
useEffect(() => {
  const c = connect(roomId);
  c.on('message', onMessage);
  return () => c.disconnect();
}, [roomId]);            // KHÔNG đưa onMessage vào deps — lint sẽ báo nếu đưa vào

Giới hạn (theo tài liệu react.dev): chỉ gọi từ bên trong effect, không truyền cho component khác, không gọi trong lúc render. Nó không phải cách để "lách" lint cho mọi thứ.

3.5 React Compiler: memo tự động ở build time

Compiler phân tích từng component, gom code thành các reactive scope, rồi sinh code cache kết quả vào một mảng $ = _c(n) gắn với Fiber. Output thật (lab):

export function List(t0) {
  const $ = _c(10);                 // 10 ô cache cho component này
  const { items } = t0;
  ...
  let t3;
  if ($[3] !== tick) {              // chỉ tạo lại <b> khi tick đổi
    t3 = _jsx("b", { children: tick });
    $[3] = tick; $[4] = t3;
  } else {
    t3 = $[4];                      // cùng object element → React bailout, không render lại nhánh
  }

Hai quyết định thiết kế quan trọng:

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

Đảm bảoKhông đảm bảo / giới hạn
Cleanup của effect chạy trước lần effect kế tiếp và khi unmountuseEffect chạy đúng một lần khi mount — dev Strict Mode chạy 2 lần, production có thể mount lại (Activity, Fast Refresh, quay lại trang)
Deps so bằng Object.is, theo từng phần tửSo nội dung object — không bao giờ
setState updater nhận giá trị mới nhất, theo thứ tự gọiuseMemo giữ cache mãi — tài liệu nói React có thể bỏ cache; chỉ dùng để tối ưu, không để đúng/sai
Hàm setState và dispatch có tham chiếu ổn định qua các renderHàm bạn tự tạo trong component ổn định — không, trừ khi useCallback/Compiler
Compiler 1.0 giữ đúng ngữ nghĩa khi code theo Rules of ReactCompiler memo mọi thứ — nó bỏ qua component sai luật và scope chứa hook

Giới hạn số: 50 update lồng nhau → Maximum update depth exceeded. useRef đổi 100 lần vẫn chỉ 1 render (lab [4]).

5. Failure modes

5.1 Stale closure trong interval / subscription

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 10);  // count của render #1 = 0, mãi mãi
  return () => clearInterval(id);
}, []);
[1] setInterval setCount(count + 1) sau ~11 tick, các giá trị đã render: 0,1
[1] setInterval setCount(c => c + 1) sau ~11 tick, các giá trị đã render: 0,1,2,3,4,5,6,7,8,9,10

Hệ quả: sai dữ liệu âm thầm, không lỗi. Kiểu nổ trễ: chạy đúng trong demo (chỉ tick một lần), sai khi chạy lâu.

Stale closure: interval luôn đọc count = 0 Hàng trên: closure của render đầu tiên nhớ count bằng 0. Mỗi tick interval gọi setCount(0 + 1), nên state đứng yên ở 1 dù tick lần 1, 2, 3, 4, 5. Hàng dưới: dùng updater c => c + 1, React đưa giá trị mới nhất vào, state tăng 1, 2, 3, 4, 5. closure render #1 count = 0 🔒 setCount(0 + 1) → state: 1 1 1 1 1 tick 1…5 → vẫn 1 updater function không đọc count setCount(c => c + 1) → state: 1 2 3 4 5 tick 1…5 → tăng đúng
Interval được tạo một lần ở render #1, nên nó mang theo snapshot #1 suốt đời. Updater function không đọc snapshot — React đưa giá trị mới nhất vào mỗi lần gọi.

Cách chữa theo thứ tự ưu tiên: (1) updater function nếu chỉ cần state trước đó; (2) khai đủ deps và chấp nhận effect tạo lại; (3) useEffectEvent nếu cần đọc giá trị mới mà không muốn effect chạy lại; (4) useRef giữ giá trị mới nhất — cách cuối cùng, dễ sai.

5.2 Object/hàm trong deps → effect chạy mỗi render (sự cố Cloudflare 9/2025)

Lab ở mục 3.2: 6 lần render → effect chạy 6 lần. Nếu effect gọi API, mỗi render là một request. Nếu effect gọi setState → vòng lặp.

Postmortem công khai — Cloudflare, 12/9/2025. Dashboard của Cloudflare có một useEffect đưa một object vào dependency array. Object này được tạo lại mỗi lần state/props đổi, nên effect gọi API Tenant Service lặp lại nhiều lần thay vì một lần. Cùng lúc một bản deploy của Tenant Service gặp vấn đề; lượng request thừa từ dashboard góp phần làm service quá tải, kéo theo dashboard và nhiều API ngừng hoạt động. Nguồn: blog.cloudflare.com, bài "Deep dive into Cloudflare's Sept 12 dashboard and API outage". Tóm tắt theo bài postmortem, không tái hiện được hệ thống thật; chỉ tái hiện đúng lỗi effect ở lab [3].

Bài học tech lead: frontend là một client có thể DDoS chính backend của mình. Backend cần rate limit theo user/session kể cả cho request từ app nội bộ; frontend cần metric "số request / phiên / phút" để thấy bất thường.

5.3 Effect không idempotent → gấp đôi ở dev, và ở production khi mount lại

Ví dụ hay gặp: POST analytics "page_view" trong useEffect(..., []), addEventListener không remove, mở WebSocket không đóng. Strict Mode làm lộ ra ngay (hình ở mục 3.3). Người tắt Strict Mode để "hết gọi 2 lần" là đang giấu bug. Việc chỉ được làm một lần khi app khởi động (khởi tạo SDK) thì đặt ngoài component, ở module level.

5.4 Fetch trong effect không cleanup → race condition

Phần này có lab riêng ở bài State và data fetching: response cũ về sau ghi đè response mới.

5.5 React Compiler bỏ memo mà không báo

Lab: List có 100 item (50 hiển thị), mỗi dòng là một Row. Đổi state tick không liên quan 10 lần, đếm số lần Row render.

// Bản 1 — code "tự nhiên"
function List({ items }) {
  const [picked, setPicked] = useState(null);
  const [tick, setTick] = useState(0);
  const visible = items.filter(i => i.active);
  const onPick = id => setPicked(id);
  useEffect(() => { /* ... */ });
  return <div><b>{tick}</b><ul>{visible.map(i => <Row key={i.id} item={i} onPick={onPick} />)}</ul></div>;
}
Biến thểKhông compilerCó compiler
Bản 1 (như trên)Row: 500Row: 500 — không đỡ được gì
Chỉ tách rows ra biến trước JSXRow: 500Row: 500
Chỉ đưa useEffect lên trước filterRow: 500Row: 500
Cả hai: hook trước, rows tách khỏi JSXRow: 500Row: 0

Vì sao: xem output đã compile. Bản 1: visible sống từ dòng filter tới dòng map, vắt qua lời gọi useEffect → scope chứa hook → bị bỏ memo, items.filter chạy lại mỗi render và tạo mảng mới. Khi map nằm trong JSX cạnh {tick}, scope của nó đan với scope của <b> và bị gộp chung, phụ thuộc cả tick.

Hệ quả: không sai dữ liệu, chỉ mất tối ưu mà bạn tưởng mình có. Đây là bẫy của người "bật compiler rồi xoá hết useMemo". Tài liệu chính thức cũng khuyên: với code có sẵn, giữ nguyên memo tay hoặc test kỹ trước khi xoá.

Phát hiện: React DevTools hiện nhãn "Memo ✨" cho component đã compile — nhưng nhãn chỉ nói component được compile, không nói scope nào được memo. Cách chắc chắn: Profiler đếm render, hoặc đọc output (playground.react.dev).

6. Vận hành trong production

Cấu hình và công cụ

Metric

MetricBắt được lỗi gì
Số request API / phiên / phút (theo endpoint)Effect chạy lặp (bài học Cloudflare)
Lỗi Maximum update depth exceeded trong Sentry/APMVòng setState trong effect
Bộ nhớ tab tăng dần (heap snapshot)Listener, interval, socket không cleanup
Số CompileError của compiler theo buildComponent bị bỏ qua tối ưu tăng lên sau mỗi release

Debug lúc 3h sáng

Tab Network lọc theo endpoint, xem request lặp đều đặn → tìm useEffect gọi endpoint đó → xem deps có object/hàm tạo trong render không. Hotfix nhanh ở backend: rate limit theo session; ở frontend: đổi deps sang giá trị nguyên thủy (user.id thay cho user).

7. So sánh và ma trận quyết định

Đặt logic ở đâu

Việc cần làmChỗ đúngSai phổ biến
Giá trị suy ra từ props/state (lọc, tổng tiền)Tính thẳng trong render (thêm useMemo nếu nặng)useEffect + setState → render 2 lần, có khoảnh khắc sai
Phản ứng với hành động người dùng (submit, click)Event handleruseEffect theo dõi cờ submitted
Đồng bộ với hệ thống ngoài (socket, DOM API, widget bên thứ ba)useEffect có cleanupKhông cleanup
Đọc store ngoài ReactuseSyncExternalStoreuseEffect + setState (tearing — xem bài State)
Lấy dữ liệu serverTanStack Query / Server Component / loader của frameworkuseEffect + fetch tự viết
Giá trị cần nhớ nhưng không hiển thị (id timer, giá trị trước)useRefuseState → render thừa

Memo: tay, compiler, hay không làm gì

Không memouseMemo/useCallback/memo tayReact Compiler 1.0
Công sức0Cao, dễ sai depsThấp (cấu hình build)
Độ chính xác—Hay hỏng vì một prop là object mớiTốt khi code đúng luật; bỏ qua âm thầm khi sai
Rủi roChậm khi cây lớnCode rối, bug depsThêm bước build; khó debug output; phụ thuộc hình dạng code
Dùng compiler khi: code mới, team đã bật lint compiler, có Profiler/đo đạc để kiểm.
Tránh (hoặc bật dần) khi: codebase cũ nhiều chỗ mutate props/đọc ref trong render, thư viện bên thứ ba dựa vào tham chiếu mới mỗi render, chưa có cách đo render.

8. Góc nhìn tech lead: tác động tổ chức

9. Anti-patterns và bài học thực tế

  1. useEffect để tính state suy ra: useEffect(() => setTotal(sum(items)), [items]). Thừa một render và có một frame hiển thị total cũ. Tính thẳng: const total = sum(items).
  2. Chuỗi effect: effect A set state X → effect B theo X set Y → effect C... Không ai lần ra luồng. Gom về event handler.
  3. Tắt exhaustive-deps để "effect chỉ chạy một lần" → stale closure.
  4. Đưa object nguyên con vào deps ([user], [options]) — bài học Cloudflare. Dùng giá trị nguyên thủy: [user.id].
  5. useCallback mọi hàm nhưng component con không memo → chỉ tốn thêm, không được gì.
  6. Copy props vào state: const [name, setName] = useState(props.name) — props đổi sau đó thì state không đổi theo. Nếu cần "reset khi đổi đối tượng" thì đổi key của component.
  7. Đọc/ghi ref.current trong render — kết quả không đoán được trong concurrent, và compiler bỏ qua component đó (lab: Cannot access refs during render).
  8. Tắt Strict Mode vì "API bị gọi 2 lần".

Điều người làm lâu đều biết: hầu hết useEffect trong codebase trung bình không nên tồn tại. Tài liệu react.dev có hẳn một trang "You Might Not Need an Effect". Đếm số useEffect trong PR là một chỉ số review nhanh và hiệu quả.

10. Câu hỏi phụ interviewer hay hỏi tiếp

H: Vì sao không được gọi hook trong if?
Đ: React lưu hook theo thứ tự gọi, không theo tên. Gọi có điều kiện làm lệch thứ tự → hook đọc nhầm ô nhớ. Lab: "Rendered more hooks than during the previous render".

H: useEffect với [] có phải componentDidMount?
Đ: Không hẳn. Nó là "đồng bộ với hệ thống ngoài khi mount, huỷ khi unmount". Strict Mode chạy setup-cleanup-setup ở dev; production cũng có thể mount lại. Viết sao cho chạy lại vẫn đúng.

H: useMemo và useCallback khác gì?
Đ: useCallback(fn, deps) chính là useMemo(() => fn, deps). Cả hai chỉ có ích khi giá trị được so tham chiếu ở đâu đó: prop của component memo, hoặc deps của effect khác.

H: Có React Compiler rồi còn cần useMemo?
Đ: Code mới thì hầu như không, trừ khi cần kiểm soát chính xác (ví dụ giá trị làm deps của effect). Code cũ thì giữ nguyên memo tay. Và phải đo — lab cho thấy compiler có thể không memo được gì nếu scope vắt qua hook.

H: useRef khác biến ngoài component thế nào?
Đ: Biến module dùng chung cho mọi instance của component; ref thì riêng cho từng instance và sống theo vòng đời của nó.

H: useLayoutEffect trên server?
Đ: Server không có layout, nó không chạy; React sẽ cảnh báo khi SSR. Dùng useEffect, hoặc chỉ render phần đó ở client.

H: Làm sao đọc state mới nhất trong callback của thư viện ngoài (socket, map)?
Đ: useEffectEvent nếu callback được đăng ký trong effect. Ngoài ra dùng ref cập nhật mỗi lần commit — nhưng đó là lối thoát cuối cùng.

H: Custom hook có chia sẻ state giữa các component không?
Đ: Không. Mỗi lần gọi custom hook tạo ô nhớ riêng trên Fiber của component gọi nó. Muốn chia sẻ thì nâng state lên, dùng Context, hoặc store ngoài.

Phụ lục: lab đã chạy

Môi trường: như bài Cơ chế render (Docker node:22-bookworm-slim, React 19.3.0, jsdom 26.1, tsx), thêm @babel/core 8.0.6, @babel/preset-react 8.0.1, babel-plugin-react-compiler 1.0.0.

docker exec react-lab npm i @babel/core @babel/preset-react babel-plugin-react-compiler@1
docker exec react-lab npx tsx 05-hooks.tsx                      # dev
docker exec -e NODE_ENV=production react-lab npx tsx 05-hooks.tsx   # so sánh Strict Mode ở production
docker exec react-lab node rc/build.mjs && docker exec react-lab npx tsx rc/run.mjs   # compiler

05-hooks.tsx — phần stale closure và deps (rút gọn):

function StaleTimer({ fix }) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => (fix ? setCount(c => c + 1) : setCount(count + 1)), 10);
    return () => clearInterval(id);
  }, []);
  shown.push(count);
  return null;
}
function Deps({ memo }) {
  const [, set] = useState(0); rerender = () => set(x => x + 1);
  const raw = { page: 1 };
  const stable = useMemo(() => ({ page: 1 }), []);
  useEffect(() => { runs++; }, [memo ? stable : raw]);
  return null;
}

rc/build.mjs — compile có logger để thấy component bị bỏ qua:

import { transformFileSync } from '@babel/core';
const events = [];
const { code } = transformFileSync('rc/components.jsx', {
  babelrc: false, configFile: false,
  plugins: [['babel-plugin-react-compiler', { logger: { logEvent: (file, e) => events.push(e) } }]],
  presets: [['@babel/preset-react', { runtime: 'automatic' }]],
});
// events: { kind: 'CompileSuccess', fnName: 'List' } | { kind: 'CompileError', detail: { reason: 'Cannot access refs during render' } }

Bẫy khi tự dựng lab compiler: lần đầu tôi đếm render bằng counts.Row++ ngay trong component — compiler từ chối compile cả Row lẫn List (This value cannot be modified) vì đó là mutate biến global trong render. Phải đếm qua một custom hook import từ file không compile (useRenderCount('Row')).