Memoization & the Compiler
ปัญหาที่ memoization แก้
หัวข้อที่มีชื่อว่า “ปัญหาที่ memoization แก้”ทุก render จะรัน component ใหม่ ซึ่งสร้าง value ทุกตัวข้างในใหม่หมด — รวมถึง object, array และ function พวกนี้เป็น reference ใหม่ทุกครั้ง แม้เนื้อหาจะเหมือนเดิม ปกติแล้วไม่เป็นไร แต่เป็นปัญหาใน 2 กรณี:
- การคำนวณที่แพง รันทุก render ทั้งที่ input ไม่เปลี่ยน
- reference ใหม่ (function หรือ object) ที่ส่งให้ memoized child ทำลาย memoization ของ child ทำให้ re-render โดยไม่จำเป็น
tool memoization สามตัวจัดการคนละส่วน:
| Tool | cache | ใช้เมื่อ |
|---|---|---|
useMemo(fn, deps) | value (ผลของ fn) | การคำนวณแพง หรือต้องการ object/array reference ที่ stable |
useCallback(fn, deps) | function (reference ที่ stable) | ส่ง callback ให้ memoized child (คือ useMemo สำหรับ function) |
React.memo(Component) | render ของ component | component re-render ด้วย props เดิมและการ render นั้น costly |
// Expensive calc, cached until inputs change:const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// Stable function reference so a memoized child does not re-render:const handleSelect = useCallback((id) => setSelected(id), []);
// Skip re-render when props are shallowly equal:const Row = React.memo(function Row({ item }) { /* ... */ });พิสูจน์: object reference ใหม่ทำลาย React.memo
หัวข้อที่มีชื่อว่า “พิสูจน์: object reference ใหม่ทำลาย React.memo”import { memo } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
let renderCount = 0;
const Row = memo(function Row({ style }: { style: { color: string } }) { renderCount++; return <li style={{ color: style.color }}>row</li>;});
test('a new object literal passed as a prop defeats React.memo — the child re-renders every time', () => { renderCount = 0; function Parent() { // 🔴 { color: 'red' } is a NEW object reference every render. return <Row style={{ color: 'red' }} />; } const { rerender } = render(<Parent />); expect(renderCount).toBe(1); rerender(<Parent />); // nothing Row's props actually changed rerender(<Parent />); expect(renderCount).toBe(3); // memo did nothing — re-rendered every time});
test('a stable object reference lets React.memo actually skip re-renders', () => { renderCount = 0; const stableStyle = { color: 'red' }; function Parent() { return <Row style={stableStyle} />; } const { rerender } = render(<Parent />); expect(renderCount).toBe(1); rerender(<Parent />); rerender(<Parent />); expect(renderCount).toBe(1); // same reference — memo skips the re-render});✓ a new object literal passed as a prop defeats React.memo — the child re-renders every time✓ a stable object reference lets React.memo actually skip re-renders
Test Files 1 passed (1) Tests 2 passed (2)เมื่อ memoization ช่วย — และเมื่อกลับทำร้าย
หัวข้อที่มีชื่อว่า “เมื่อ memoization ช่วย — และเมื่อกลับทำร้าย”memoization ไม่ฟรี: เสีย memory และเสียการเทียบทุก render ใส่ไว้ทุกที่ “เผื่อไว้” ทำให้ code รกและอาจติดลบสุทธิ memoization ช่วยจริงเมื่อ:
- การคำนวณที่ cache นั้นแพงแบบวัดได้ หรือ
- ต้องการ reference ที่ stable เพื่อกัน
React.memochild (หรือ effect dependency) ไม่ให้รันใหม่
สำหรับค่าที่ถูก — string concat, .filter เล็ก ๆ — คำนวณตอน render ดีกว่าและชัดกว่า อย่าหยิบ useMemo มาใช้แบบอัตโนมัติ
flowchart TB render["parent re-renders"] --> newfn["new function reference created each render"] newfn --> child["memoized child sees a 'changed' prop"] child --> rerender["child re-renders needlessly"] fix["useCallback → stable reference"] --> skip["memoized child skips re-render"]
React Compiler เปลี่ยนสมการ
หัวข้อที่มีชื่อว่า “React Compiler เปลี่ยนสมการ”React มี compiler ที่ memoize component และ value ของคุณอัตโนมัติตอน build — compiler แทรกสิ่งที่เทียบเท่า useMemo/useCallback/memo ให้ตรงจุดที่ช่วยจริง โดยวิเคราะห์ code ของคุณ จุดประสงค์ทั้งหมดคือ คุณไม่ควรต้อง memoize เอง
แนวทางปฏิบัติสำหรับ project React 19+:
- เปิด compiler: เขียน code ตรงไปตรงมา อย่าโปรย
useMemo/useCallback— compiler จัดการ memoization ให้ ถูกต้องกว่าและละเอียดกว่าการจูนมือส่วนใหญ่ หน้าที่คุณคือทำให้ component pure (compiler พึ่ง Rules of Hooks และ purity เพื่อทำงานอย่างปลอดภัย) - ไม่เปิด compiler: tool มือด้านบนยังใช้ได้ — หยิบมาเฉพาะที่ bottleneck จริงที่วัดได้
ทิศทางชัดเจน: manual memoization กำลังกลายเป็น optimization ทางเลือกสุดท้าย ไม่ใช่นิสัยเริ่มต้น ทำให้ component pure แล้วให้ compiler จัดการงานจดจำให้
อ่าน compiler output จริง
หัวข้อที่มีชื่อว่า “อ่าน compiler output จริง”“compiler auto-memoize ให้” เชื่อเฉย ๆ ก็ได้แต่ทำให้เป็นรูปธรรมได้ด้วยการรัน babel-plugin-react-compiler (package จริงที่ขับเคลื่อน compiler เวอร์ชัน 1.0.0 — major version เดียวกับที่ประกาศ release ของ react.dev เองเรียกว่า stable) กับ component ธรรมดาที่ไม่มี manual memoization เลย:
// src/components/TodoList.jsx — the SOURCE, no useMemo/useCallback anywherefunction TodoList({ todos, filter, onSelect }) { const visibleTodos = todos.filter((t) => t.status === filter); const handleSelect = (id) => onSelect(id); return ( <ul> {visibleTodos.map((t) => ( <li key={t.id} onClick={() => handleSelect(t.id)}> {t.text} </li> ))} </ul> );}Output จริงของ npx babel TodoList.jsx --plugins babel-plugin-react-compiler:
import { c as _c } from "react/compiler-runtime";import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";function TodoList(t0) { const $ = _c(12); const { todos, filter, onSelect } = t0; let t1; if ($[0] !== filter || $[1] !== onSelect || $[2] !== todos) { let t2; if ($[4] !== filter) { t2 = t => t.status === filter; $[4] = filter; $[5] = t2; } else { t2 = $[5]; } const visibleTodos = todos.filter(t2); let t3; if ($[6] !== onSelect) { t3 = id => onSelect(id); $[6] = onSelect; $[7] = t3; } else { t3 = $[7]; } const handleSelect = t3; let t4; if ($[8] !== handleSelect) { t4 = t_0 => /*#__PURE__*/_jsxDEV("li", { onClick: () => handleSelect(t_0.id), children: t_0.text }, t_0.id, false); $[8] = handleSelect; $[9] = t4; } else { t4 = $[9]; } t1 = visibleTodos.map(t4); $[0] = filter; $[1] = onSelect; $[2] = todos; $[3] = t1; } else { t1 = $[3]; } let t2; if ($[10] !== t1) { t2 = /*#__PURE__*/_jsxDEV("ul", { children: t1 }, void 0, false); $[10] = t1; $[11] = t2; } else { t2 = $[11]; } return t2;}อ่าน $ เป็น memoization array ต่อ component (useMemoCache(12) ข้างใต้) โดยมี slot คู่หนึ่งต่อ expression ที่ cache ไว้ ทุก if ($[n] !== someValue) คือสิ่งที่ compiler สร้างอัตโนมัติเทียบเท่า dependency check ของ useMemo/useCallback: filter callback (t2) ถูกสร้างใหม่แค่ตอน filter เปลี่ยน, handleSelect (t3) แค่ตอน onSelect เปลี่ยน, ตัว render <li> ทั้งก้อน (t4) แค่ตอน handleSelect เปลี่ยน และ <ul> ชั้นนอก (t2 ตัวสุดท้าย) แค่ตอน list ที่ map แล้ว (t1) เปลี่ยน นั่นคือ memoization graph ที่ ละเอียดกว่า ที่ developer ส่วนใหญ่จะเขียนมือ — compiler track sub-expression แต่ละตัวแยกกัน แทนที่จะ memoize ทั้ง component ด้วย React.memo ตัวเดียว
เมื่อไร manual memoization ยังจำเป็น
หัวข้อที่มีชื่อว่า “เมื่อไร manual memoization ยังจำเป็น”แม้เปิด compiler แล้ว ยังมีกรณีจริงที่ useMemo/useCallback/memo ยังคุ้มค่า:
- compiler ไม่ได้รันกับ code นั้น มันแปลงเฉพาะ component และ hook ที่ตรวจสอบแบบ static ได้ว่าทำตาม Rules of React; code ที่มันวิเคราะห์ไม่ได้อย่างปลอดภัย (หรืออยู่นอก directory/package ที่ถูก compile) จะไม่ถูกแตะและไม่ได้อะไรฟรีเลย
- value ต้องการ stable สำหรับสิ่งที่อยู่ นอก render ของ React — effect dependency, value ที่ส่งให้ library ที่ไม่ใช่ React ซึ่งเช็ก reference-equality เอง หรือ cache key ระดับ module
- คุณยังไม่ได้ใช้ compiler code production จำนวนมาก และ library ส่วนใหญ่ของ ecosystem ยัง ship โดยไม่มีมัน — tool มือยังเป็นทางเดียวตรงนั้น
- compatibility mode (
"use no memo") compiler ให้ไฟล์หรือ function opt-out จากการ compile ทั้งหมดได้เมื่อ code ของมันไม่ตาม Rules of React พอที่จะ memoize อย่างปลอดภัย — ใน escape hatch นั้น คุณกลับไปใช้ manual memoization ตามที่ตั้งใจ
หลักที่เคยใช้ไม่ได้กลับด้าน แค่แคบลง: หยิบ manual memoization มาใช้เมื่อมีเหตุผลจำเพาะที่วัดได้ว่า compiler ครอบคลุมไม่ถึง ไม่ใช่นิสัยเริ่มต้น
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”compiler ไม่ใช่ “useMemo ที่ฉลาดขึ้น” ที่ต่อทับ code ของคุณ — มันคือ static analysis pass ที่เขียน component ของคุณใหม่ให้เป็นรูปทรงเดียวกับที่ engineer ที่มีวินัยสูงมากจะเขียนมือ: cache slot หนึ่งต่อ sub-expression หนึ่ง แต่ละอันมี dependency check ของตัวเอง useMemo/useCallback แบบมือคือไอเดียเดียวกัน แค่หยาบกว่าและต้องดูแลเอง พอเห็น pattern $[n] !== value ด้านบนแล้ว “compiler auto-memoize ให้” จะเลิกเป็นคำขวัญ แล้วกลายเป็นข้อเท็จจริงเชิงกลไกที่คุณอ่านได้จาก output ตรง ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
| ให้ compiler memoize ให้ | ละเอียดกว่า sync กับ code อัตโนมัติ ไม่ต้องดูแล dependency array มือ | ต้อง setup toolchain และครอบคลุมเฉพาะ code ที่ตรวจสอบแบบ static ได้ว่าปลอดภัยกับ compiler |
useMemo/useCallback/memo แบบมือ | ใช้ได้ทุกที่ รวมถึงนอก compiled code และ React เวอร์ชันเก่า | หยาบกว่า ใช้น้อยไปหรือมากไปได้ง่าย ต้องดูแล dependency array เอง |
opt-out "use no memo" | ให้ไฟล์หนึ่งข้าม compile ได้เมื่อไม่ตาม Rules of React | คุณกลับไปรับผิดชอบ memoization เต็มตัวในไฟล์นั้น |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “compiler ทำให้
useMemo/useCallbackล้าสมัยไปเลยทุกที่” เฉพาะ code ที่มันแปลงจริงและตรวจสอบได้ว่าตาม Rules of React — code ที่ไม่ถูกแตะ, package ที่ไม่ถูก compile และความต้องการ stable ของ effect dependency ยังได้ประโยชน์จาก manual memoization - “compiler output แปลว่า component ตัวมันเองถูก memoize เหมือนห่อด้วย
React.memo” output ด้านบนแสดง memoization ระดับ expression ในตัว function body ไม่ใช่React.memoที่ห่อทั้ง component — ทั้งสองเสริมกัน ไม่ใช่กลไกเดียวกัน - “inline object prop เป็นแค่ความไม่มีประสิทธิภาพเล็กน้อย” พิสูจน์แล้วด้านบน: มันทำลาย
React.memoเต็ม ๆ — child re-render ทุก render ของ parent จริง ๆ ไม่ใช่แค่ “มากกว่าที่เหมาะสมนิดหน่อย” - “ถ้าใส่
useMemoทุกที่ compiler จะข้ามไปเอง” compiler เก็บ memoization ที่เขียนมือไว้ที่มันตรวจสอบได้ว่าถูกต้อง แทนที่จะ override แบบไม่ดู — ดังนั้นuseMemoมือที่ซ้ำซ้อนไม่อันตราย แต่ก็ไม่ได้ให้อะไรเพิ่มเหนือกว่าที่ compiler จะทำให้อยู่แล้วอย่างละเอียดกว่า
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ generate มาใช้ (หรือข้าม) memoization ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- object, array หรือ function literal ถูกสร้าง inline แล้วส่งเป็น prop ให้ component ที่ห่อด้วย
React.memoหรือเปล่า? แบบนั้นทำลาย memoization แบบเงียบ ๆ ทุกครั้ง - project มี React Compiler ตั้งค่าไว้จริงไหม (เช็ก build config / babel plugin) หรือ code สมมติว่ามี auto-memoization ระดับ compiler ที่จริง ๆ ไม่ได้รันอยู่?
useMemo/useCallbackห่อการคำนวณที่ถูกจริง ๆ “เผื่อไว้” เพิ่มความรกโดยไม่มีประโยชน์วัดได้หรือเปล่า?- ถ้าไฟล์มี
"use no memo"(หรือถูกยกเว้นจากการ compile ด้วยวิธีอื่น) มันยังพึ่ง manual memoization ตรงจุดที่สำคัญไหม? - dependency array ของ memoized value ขาด value ที่มันอ่านหรือเปล่า — คืนค่า cache เก่าค้างแบบเงียบ ๆ แทนที่จะคำนวณใหม่?
🐛 หา bug ในโค้ดนี้
const Row = React.memo(function Row({ style }) {return <li style={style}>{style.label}</li>;});
function List({ items }) {return ( <ul> {items.map((item) => ( <Row key={item.id} style={{ color: item.color, label: item.text }} /> ))} </ul>);}เฉลย
Row ถูกห่อด้วย React.memo แต่ทุก call site สร้าง object literal { color, label } ใหม่ทุกครั้งที่ List render — การเทียบ prop แบบ shallow ของ Row จึงเห็น style prop ที่ “ต่างออกไป” เสมอ แม้ item.color/item.text จะไม่เปลี่ยนเลย React.memo เลยไม่มีโอกาสข้ามอะไรทั้งสิ้น พิสูจน์แล้วในโพรบด้วย pattern เดียวกัน: memoized child ที่ได้รับ inline object ใหม่ทุกครั้ง re-render ทุก render ของ parent (parent render 3 ครั้ง child render 3 ครั้ง) — ในขณะที่ child ตัวเดียวกันที่ได้รับ stable object reference render แค่ครั้งเดียวตลอด 3 render ของ parent ทางแก้คือสร้าง style object ด้วย useMemo ที่ key ด้วย item.color/item.text หรือ restructure Row ให้รับ color/label เป็น primitive prop แยกกันแทนที่จะเป็น object เดียว — primitive เทียบด้วย value ได้ถูกต้อง จึงไม่มีปัญหา new-reference ตั้งแต่แรก