ข้ามไปยังเนื้อหา

Memoization & the Compiler

ทุก render จะรัน component ใหม่ ซึ่งสร้าง value ทุกตัวข้างในใหม่หมด — รวมถึง object, array และ function พวกนี้เป็น reference ใหม่ทุกครั้ง แม้เนื้อหาจะเหมือนเดิม ปกติแล้วไม่เป็นไร แต่เป็นปัญหาใน 2 กรณี:

  1. การคำนวณที่แพง รันทุก render ทั้งที่ input ไม่เปลี่ยน
  2. reference ใหม่ (function หรือ object) ที่ส่งให้ memoized child ทำลาย memoization ของ child ทำให้ re-render โดยไม่จำเป็น

tool memoization สามตัวจัดการคนละส่วน:

Toolcacheใช้เมื่อ
useMemo(fn, deps)value (ผลของ fn)การคำนวณแพง หรือต้องการ object/array reference ที่ stable
useCallback(fn, deps)function (reference ที่ stable)ส่ง callback ให้ memoized child (คือ useMemo สำหรับ function)
React.memo(Component)render ของ componentcomponent 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 }) { /* ... */ });
src/__tests__/inline-object-breaks-memo.test.tsx
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 ไม่ฟรี: เสีย memory และเสียการเทียบทุก render ใส่ไว้ทุกที่ “เผื่อไว้” ทำให้ code รกและอาจติดลบสุทธิ memoization ช่วยจริงเมื่อ:

  • การคำนวณที่ cache นั้นแพงแบบวัดได้ หรือ
  • ต้องการ reference ที่ stable เพื่อกัน React.memo child (หรือ 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"]
referential equality คือเหตุผลที่ callback ทำลาย memoized child

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 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 anywhere
function 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 ตัวเดียว

แม้เปิด 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 ครอบคลุมไม่ถึง ไม่ใช่นิสัยเริ่มต้น

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 จะทำให้อยู่แล้วอย่างละเอียดกว่า

เมื่อ 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 ตั้งแต่แรก

useCallback ทำอะไร?
ทำไมการส่ง inline function ให้ React.memo child ถึงทำลาย memoization?
แนวทางเมื่อเปิด React Compiler คืออะไร?
เมื่อไร manual memoization คุ้มจริง (ตอนไม่มี compiler)?
ไม่มี manual useMemo/useCallback เลยสักที่ output จริงของ React Compiler ทำอะไรกับ handleSelect?
function 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>;
}
React.memo(Row) ป้องกัน re-render จริงไหมตอน List re-render ด้วย items ที่ไม่เปลี่ยน?
const Row = React.memo(function Row({ style }) {
  return <li style={style}>{style.label}</li>;
});

function List({ items }) {
  return items.map((item) => (
    <Row key={item.id} style={{ color: item.color, label: item.text }} />
  ));
}