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-compiler1.0.0 với@babel/core8.0.6 — compile thật rồi chạy thật, đếm render. So sánh dev/production bằng biếnNODE_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ỗiuseEffect. Lệnh ở phụ lục.
Bảng thuật ngữ
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| closure | Hà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 equality | So "có cùng một object trong bộ nhớ không", không so nội dung. {a:1} !== {a:1}. |
| Strict Mode | Chế độ chỉ có ở dev: render 2 lần, mount → unmount → mount lại để lộ code không pure. |
| idempotent | Chạy 1 lần hay nhiều lần kết quả như nhau. |
| React Compiler | Plugin build-time tự chèn memo cho component và hook. Bản 1.0 ra 10/2025. |
| reactive scope | Mộ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 trongif. 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.useEffectkhông phải lifecycle mà là đồng bộ với hệ thống bên ngoài; deps so bằngObject.isnê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 class | Ví dụ |
|---|---|
| Logic liên quan bị chia theo lifecycle, logic không liên quan bị gộp chung | Subscribe ở 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àng | Phả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à bind | this.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 cho | Hooks KHÔNG tối ưu cho |
|---|---|
| Tái dùng logic có state qua custom hook | Mô 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ễ test | Hiệ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:
- Mỗi render là một snapshot. Trong một lần render,
countlà 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. - 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:
- Stale closure: một hàm sống qua nhiều render (interval tạo ở render #1) mãi thấy snapshot #1.
- Dependency array: cách nói với React "effect này dùng những giá trị nào của snapshot" để nó biết lúc nào cần tạo effect mới với snapshot mới.
- Updater function
setCount(c => c + 1): không đọc snapshot, để React đưa giá trị mới nhất vào. - useRef: ô nhớ duy nhất không theo snapshot — cùng một object qua mọi render, đổi không gây render.
- Rules of Hooks: gọi hook trong
iflàm lệch số thứ tự → hook thứ 2 đọc nhầm ô của hook thứ 1.
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ì
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:
- Gặp code sai luật thì bỏ qua component đó, không báo lỗi build. Lab: component đọc
ref.currenttrong render → sự kiệnCompileError: Cannot access refs during render, component đó chạy như code thường. Nếu không bật logger/lint thì bạn không biết. - Scope nào chứa lời gọi hook thì bị bỏ memo, và các scope đan xen nhau thì bị gộp. Đây là nguyên nhân kết quả bất ngờ ở mục 5.5.
4. Đảm bảo và giới hạn
| Đảm bảo | Không đảm bảo / giới hạn |
|---|---|
| Cleanup của effect chạy trước lần effect kế tiếp và khi unmount | useEffect 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ọi | useMemo 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 render | Hà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 React | Compiler 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.
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ộtuseEffectđư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 compiler | Có compiler |
|---|---|---|
| Bản 1 (như trên) | Row: 500 | Row: 500 — không đỡ được gì |
Chỉ tách rows ra biến trước JSX | Row: 500 | Row: 500 |
Chỉ đưa useEffect lên trước filter | Row: 500 | Row: 500 |
Cả hai: hook trước, rows tách khỏi JSX | Row: 500 | Row: 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ụ
eslint-plugin-react-hooks:rules-of-hooksvàexhaustive-depsđặt mức error trong CI, không phải warning. Bản mới của plugin có thêm các rule dựa trên compiler (vd bắt đọc ref trong render). (Theo tài liệu react.dev; chưa verify version cụ thể trong lab.)- Triển khai compiler dần dần: bật cho một thư mục/một route trước, hoặc chế độ
compilationMode: 'annotation'chỉ compile hàm có"use memo";"use no memo"để loại trừ một component đang lỗi. Bậtloggertrong build để đếmCompileErrornhư lab đã làm. - Strict Mode bật ở dev, luôn luôn. Tắt nó để hết "gọi 2 lần" là giấu bug.
Metric
| Metric | Bắ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/APM | Vò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 build | Component 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àm | Chỗ đúng | Sai 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 handler | useEffect theo dõi cờ submitted |
| Đồng bộ với hệ thống ngoài (socket, DOM API, widget bên thứ ba) | useEffect có cleanup | Không cleanup |
| Đọc store ngoài React | useSyncExternalStore | useEffect + setState (tearing — xem bài State) |
| Lấy dữ liệu server | TanStack Query / Server Component / loader của framework | useEffect + fetch tự viết |
| Giá trị cần nhớ nhưng không hiển thị (id timer, giá trị trước) | useRef | useState → render thừa |
Memo: tay, compiler, hay không làm gì
| Không memo | useMemo/useCallback/memo tay | React Compiler 1.0 | |
|---|---|---|---|
| Công sức | 0 | Cao, dễ sai deps | Thấp (cấu hình build) |
| Độ chính xác | — | Hay hỏng vì một prop là object mới | Tốt khi code đúng luật; bỏ qua âm thầm khi sai |
| Rủi ro | Chậm khi cây lớn | Code rối, bug deps | Thê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
- Biến luật thành máy kiểm:
rules-of-hooks+exhaustive-deps= error trong CI. Cấm// eslint-disable-next-line react-hooks/exhaustive-depsnếu không kèm comment giải thích và người review đồng ý. Mỗi dòng disable là một stale closure tiềm năng. - Checklist review effect: (1) Việc này có thật cần effect không? (2) Có cleanup chưa? (3) Deps có object/hàm tạo trong render không? (4) Chạy 2 lần có sao không?
- Custom hook là đơn vị tái dùng, nhưng giữ logic nghiệp vụ trong hàm thuần bên dưới (
calcDiscount()) để test không cần React và mang đi được khi đổi framework (exit strategy). - ADR cho React Compiler: quyết định bật theo thư mục, chỉ số theo dõi (số
CompileError, render count của màn chính trước/sau), điều kiện rollback ("use no memo"hoặc tắt plugin — không cần sửa code). Không xoá memo tay trong cùng đợt bật compiler. - Onboarding dev backend: PHP không có closure sống lâu kiểu này. Buổi đầu tiên nên là "mỗi render là một snapshot" với chính lab stale closure ở trên.
9. Anti-patterns và bài học thực tế
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).- 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.
- Tắt
exhaustive-depsđể "effect chỉ chạy một lần" → stale closure. - Đư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]. useCallbackmọi hàm nhưng component con khôngmemo→ chỉ tốn thêm, không được gì.- 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ì đổikeycủa component. - Đọc/ghi
ref.currenttrong render — kết quả không đoán được trong concurrent, và compiler bỏ qua component đó (lab:Cannot access refs during render). - 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')).