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

Memoization in Practice

Memoization คือการ cache ผลลัพธ์ข้าม render เพื่อให้ React ข้ามการคำนวณหรือ re-render ใหม่ได้ มี 3 เครื่องมือ ทำหน้าที่ต่างกัน:

  • useMemo(fn, deps) — cache ค่าที่คืน ของการคำนวณที่แพง คำนวณใหม่แค่ตอน dependency เปลี่ยน
  • useCallback(fn, deps) — cache identity ของ function คืน function reference เดิมข้าม render จนกว่า dependency จะเปลี่ยน
  • React.memo(Component) — ห่อ component ให้ข้าม re-render เมื่อ props shallow-equal
// useMemo: skip re-sorting a big list unless the list or order changes.
const sorted = useMemo(() => items.slice().sort(compare), [items]);
// useCallback: keep a stable onSelect so a memoized child does not re-render.
const onSelect = useCallback((id) => setSelected(id), []);
// React.memo: skip re-render when props are unchanged.
const Row = React.memo(function Row({ item, onSelect }) { /* ... */ });

React.memo เทียบ prop ด้วย shallow equality (=== ต่อ prop) primitive เทียบด้วยค่า แต่ object, array และ function เทียบด้วย reference — และ component สร้างของ ใหม่เอี่ยม ทุก render

flowchart TB
  parent["Parent re-renders"] --> inline["creates NEW onClick, NEW style object"]
  inline --> memo["React.memo(Child) compares props"]
  memo --> fail["new reference !== old reference
→ shallow-equal fails → Child re-renders anyway"]
reference ใหม่ทำลาย React.memo
// ❌ A fresh function and object every render — React.memo(Child) never skips.
<Child onClick={() => doThing()} style={{ color: "red" }} />
// ✅ Stable references — now React.memo(Child) can actually skip.
const onClick = useCallback(() => doThing(), []);
const style = useMemo(() => ({ color: "red" }), []);
<Child onClick={onClick} style={style} />

นี่คือหัวใจ: React.memo คุ้มก็ต่อเมื่อ prop ทุกตัว stable ด้วย reference ซึ่งปกติแปลว่าต้อง memoize object/array/function prop ด้วย useMemo/useCallback พลาด prop เดียวแล้ว memo ไม่ได้อะไรเลย

src/__tests__/inlineFunctionBreaksMemo.test.tsx
import { memo, useState, useCallback } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);
let rowRenders = 0;
const Row = memo(function Row({ onSelect }: { onSelect: () => void }) {
rowRenders++;
return <li onClick={onSelect}>row</li>;
});
test('a fresh inline function prop defeats React.memo — the child re-renders on every parent render', () => {
rowRenders = 0;
function Parent() {
const [, setTick] = useState(0);
// 🔴 a NEW function every render.
return <Row onSelect={() => setTick((t) => t + 1)} />;
}
const { rerender } = render(<Parent />);
expect(rowRenders).toBe(1);
rerender(<Parent />);
rerender(<Parent />);
expect(rowRenders).toBe(3); // memo did nothing — re-rendered every time
});
test('useCallback keeps the reference stable, so React.memo actually skips the re-render', () => {
rowRenders = 0;
function Parent() {
const [, setTick] = useState(0);
const onSelect = useCallback(() => setTick((t) => t + 1), []);
return <Row onSelect={onSelect} />;
}
const { rerender } = render(<Parent />);
expect(rowRenders).toBe(1);
rerender(<Parent />);
rerender(<Parent />);
expect(rowRenders).toBe(1); // stable reference — memo skips the re-render
});
✓ a fresh inline function prop defeats React.memo — the child re-renders on every parent render
✓ useCallback keeps the reference stable, so React.memo actually skips the re-render
Test Files 1 passed (1)
Tests 2 passed (2)
import { useState, useMemo, memo } from 'react';
const MemoChild = memo(function MemoChild({ style }: { style: { color: string } }) {
return <li style={style}>Memo child</li>;
});
export default function App() {
const [tick, setTick] = useState(0);
const [useStable, setUseStable] = useState(false);
const stableStyle = useMemo(() => ({ color: 'inherit' }), []);
const style = useStable ? stableStyle : { color: 'inherit' };
return (
<div>
<button onClick={() => setTick((t) => t + 1)}>Parent tick: {tick}</button>
<button onClick={() => setUseStable((s) => !s)}>
Mode: {useStable ? 'useMemo (stable)' : 'inline object (fresh every render)'}
</button>
<ul>
<MemoChild style={style} />
</ul>
</div>
);
}
Babel 7.29.9 (jsdelivr) · React 19.3.0 dev build (esm.sh, pinned)
ComponentRendersLast commit (ms)
Click Run — commits will appear here.

คลิก Parent tick ตอนโหมดเป็น “inline object”: แถว MemoChild จะเพิ่มทุกครั้งที่คลิก เพราะ { color: 'inherit' } เป็น object ใหม่ทุก render แล้วกดปุ่มโหมดสลับเป็น “useMemo (stable)” แล้วคลิก Parent tick อีกครั้ง: แถว MemoChild จะหยุดขยับเลย เพราะ object reference เดิมจาก useMemo ผ่าน shallow comparison ของ React.memo ทุกครั้ง

useCallback ให้แค่ reference ที่ stable reference นั้นจะหยุด re-render ได้ก็ต่อเมื่อมีอะไรข้างล่างเช็ก equality กับมันจริง ๆ — ซึ่งแปลว่า component ที่รับมันต้องถูกห่อด้วย React.memo callback ที่ stable ที่ส่งให้ component ธรรมดาที่ไม่ memoize ไม่เปลี่ยนอะไรเลย: component นั้น re-render ทุกครั้งที่ parent re-render เหมือน child ทั่วไป ไม่ว่า prop มันจะ stable แค่ไหน

src/__tests__/useCallbackNeedsMemoizedChild.test.tsx
import { memo, useState, useCallback } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);
let plainRenders = 0;
let memoRenders = 0;
function PlainRow({ onSelect }: { onSelect: () => void }) {
plainRenders++;
return <li onClick={onSelect}>row</li>;
}
const MemoRow = memo(function MemoRow({ onSelect }: { onSelect: () => void }) {
memoRenders++;
return <li onClick={onSelect}>row</li>;
});
test('useCallback alone does not stop a re-render — the child must be memoized too', () => {
plainRenders = 0;
function Parent() {
const [, setTick] = useState(0);
const onSelect = useCallback(() => setTick((t) => t + 1), []);
// PlainRow is NOT wrapped in React.memo.
return <PlainRow onSelect={onSelect} />;
}
const { rerender } = render(<Parent />);
expect(plainRenders).toBe(1);
rerender(<Parent />);
rerender(<Parent />);
// A stable callback changes nothing here: a non-memoized component
// re-renders whenever its parent does, regardless of prop stability.
expect(plainRenders).toBe(3);
});
test('the same stable callback DOES stop a re-render once the child is wrapped in React.memo', () => {
memoRenders = 0;
function Parent() {
const [, setTick] = useState(0);
const onSelect = useCallback(() => setTick((t) => t + 1), []);
return <MemoRow onSelect={onSelect} />;
}
const { rerender } = render(<Parent />);
expect(memoRenders).toBe(1);
rerender(<Parent />);
rerender(<Parent />);
expect(memoRenders).toBe(1); // memo + stable reference — the pairing works
});
✓ useCallback alone does not stop a re-render — the child must be memoized too
✓ the same stable callback DOES stop a re-render once the child is wrapped in React.memo
Test Files 1 passed (1)
Tests 2 passed (2)

useCallback กับ React.memo แยกกันไม่ออก: ตัวหนึ่งให้ reference ที่ stable อีกตัวคือสิ่งที่ทำงานกับมันจริง ใช้แค่ตัวเดียวไม่ได้อะไรเลย

บทก่อนพูดถึง “ยก content ขึ้นด้วย composition” เป็นการแก้เชิงโครงสร้าง — นี่คือหลักฐาน เมื่อ component ที่ถือ state ของตัวเอง render {children} ที่มันได้รับมาเป็น prop ค่า children นั้นคือ element reference เดิมเป๊ะ ทุกครั้งที่ component นั้น re-render เอง (parent ของมันสร้าง element นั้นครั้งเดียว component ที่ถือ ticking state ไม่เคยสร้างมันใหม่) reconciler ของ React จึง bail out จาก subtree นั้นได้ทั้งหมดโดยไม่ต้องเรียก function ของ child component ใหม่เลยด้วยซ้ำ — ไม่ต้องมี React.memo, useMemo, useCallback เข้ามาเกี่ยวข้อง

src/__tests__/childrenAsPropsStopsRerenders.test.tsx
import { useState, type ReactNode } from 'react';
import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(cleanup);
let expensiveRenders = 0;
function Expensive() {
expensiveRenders++;
return <p>expensive</p>;
}
// ❌ Ticker builds `<Expensive />` itself, inside its own render — every
// tick re-creates it, so it re-renders along with Ticker.
function TickerBuildsItsOwnChild() {
const [tick, setTick] = useState(0);
return (
<div>
<button onClick={() => setTick((t) => t + 1)}>tick: {tick}</button>
<Expensive />
</div>
);
}
// ✅ Ticker receives `children` from its OWN parent instead — its local
// `tick` state changing does not touch that prop at all.
function Ticker({ children }: { children: ReactNode }) {
const [tick, setTick] = useState(0);
return (
<div>
<button onClick={() => setTick((t) => t + 1)}>tick: {tick}</button>
{children}
</div>
);
}
test('Expensive built directly inside the ticking component re-renders on every tick', () => {
expensiveRenders = 0;
render(<TickerBuildsItsOwnChild />);
expect(expensiveRenders).toBe(1);
act(() => {
screen.getByText(/tick: 0/).click();
});
act(() => {
fireEvent.click(screen.getByText(/tick: 1/));
});
expect(expensiveRenders).toBe(3); // Expensive re-renders with every tick
});
test('the same component passed in as `children` does NOT re-render when the wrapper\'s OWN state ticks', () => {
expensiveRenders = 0;
render(
<Ticker>
<Expensive />
</Ticker>,
);
expect(expensiveRenders).toBe(1);
act(() => {
screen.getByText(/tick: 0/).click();
});
act(() => {
fireEvent.click(screen.getByText(/tick: 1/));
});
// Ticker re-rendered twice (its button label changed), but the `children`
// prop it holds is the exact same element reference both times — React
// bails out of that subtree entirely, so Expensive never re-renders.
expect(expensiveRenders).toBe(1);
});
✓ Expensive built directly inside the ticking component re-renders on every tick
✓ the same component passed in as `children` does NOT re-render when the wrapper's OWN state ticks
Test Files 1 passed (1)
Tests 2 passed (2)

trick นี้ช่วยกันแค่ re-render ที่เกิดจาก component ที่ถือ children เท่านั้น ถ้าตัวที่สร้าง children element นั้น re-render เอง (ancestor ของมัน) element ใหม่จะถูกสร้างและส่งลงมาอีกครั้ง แล้วกฎการ propagate จากบทก่อนก็ใช้ตามปกติ

useMemo คุ้มค่าตอนการคำนวณ แพงจริง ๆ — sort หรือ filter data เป็นพัน, คำนวณหนัก, สร้าง derived data structure React Compiler เปลี่ยนว่าใครควรเป็นคนเขียน useMemo นั้น: ดู Memoization & the Compiler ว่า compiler อ่าน component ธรรมดาที่ไม่ memoize เลยแล้วแทรก caching เทียบเท่าให้เองยังไง ละเอียดกว่า code ที่เขียนมือส่วนใหญ่ — บทนั้นแสดง output จริงของ compiler กับการคำนวณแบบนี้เป๊ะ ๆ

พอเปิด compiler บน codebase แล้ว แนวทางเปลี่ยนไป:

  • อย่า เขียน useMemo/useCallback มือเป็น default — compiler จัดการ case ทั่วไปให้
  • ต้อง ทำให้ component pure และตาม Rules of Hooks — นั่นคือสิ่งที่ทำให้ compiler optimize ได้อย่างปลอดภัย
  • เก็บไว้ใช้ manual memoization เฉพาะ case หายากที่ compiler จัดการไม่ได้ หรือ profiler ยังชี้ปัญหาอยู่

จนกว่า compiler จะครอบคลุมทุกที่ hand-memoization ยังคุ้มค่าใน case เฉพาะบางอัน:

  • การคำนวณที่ แพงจริง ๆ (sort/filter data เป็นพัน, คำนวณหนัก) → useMemo
  • value/function ที่ส่งให้ memoized child หรือ effect dependency ที่ reference ใหม่จะทำให้ render/effect รันเพิ่ม → useCallback/useMemo
  • subtree ใหญ่ที่ถูก bypass บ่อย → React.memo

นอกเหนือจากนั้น การ memoize มักเสียมากกว่าที่ได้ (memory + การเทียบ + ความรก) วัดก่อนเสมอ

React.memo ไม่ฟรี: ทุก render ยังต้องจ่ายค่าเทียบ prop แบบ shallow และ component ที่ memoize ยังถือ output ที่ render ล่าสุดไว้ใน memory ห่อ component ด้วย memo กลายเป็นต้นทุนสุทธิเมื่อ:

  • component render ถูกอยู่แล้ว การเทียบ prop เสียต้นทุนพอ ๆ กับแค่ re-render component ธรรมดา — จ่ายต้นทุนแต่ไม่ได้อะไรกลับมา
  • prop เปลี่ยนแทบทุก render อยู่แล้ว ถ้า prop ของ component ที่ memoize ไม่มีทาง stable จริง (เช่น มันได้ derived data ใหม่เสมอ) การเทียบรันทุกครั้งและไม่เคยคุ้มเลย — เป็น overhead ล้วน ๆ
  • ไม่มีใครทำให้ prop ของ child stable อย่างที่แสดงด้านบน React.memo บน component ที่ caller ยังส่ง inline object/function อยู่ ทำงานเทียบ prop แล้วก็ re-render อยู่ดี — แย่กว่าไม่ memoize ด้วยซ้ำ เพราะตอนนี้จ่ายทั้งค่าเทียบที่ fail และ re-render เต็ม

หลักคร่าว ๆ คือ: หยิบ React.memo มาใช้กับ component ที่ profile แล้วว่าทั้งแพงและ prop stable — ไม่ใช่ใส่แบบอัตโนมัติกับทุกอย่าง

มองว่า React.memo เป็น ประตูที่ล็อกไว้ หน้า component: มันเปิด (ข้าม re-render) ก็ต่อเมื่อทุก prop ที่ยื่นให้มัน === กับที่เห็นครั้งก่อน useMemo/useCallback คือ กุญแจ ที่ทำให้ value หรือ function เป็น object เดิมจริง ๆ ข้าม render เพื่อให้ประตูจำได้ การส่ง children ลงมาจากที่สูงกว่าคือทางที่สาม ไม่ต้องมีประตูเลย: ค่านั้นไม่เคยถูกสร้างใหม่ตั้งแต่แรก จึงไม่มีอะไรให้เช็ก “prop เปลี่ยน” มาสะดุด

ทางเลือกประโยชน์ต้นทุน
useMemo/useCallback + React.memo (คู่กัน)ข้าม re-render ของ subtree ที่แพงจริงและ prop stableต้องทำทั้งสองฝั่งให้ถูก พลาด prop เดียวแล้วคู่นี้ไม่ทำอะไรเลย
Children เป็น propsไม่มี memoization API เลย เชิงโครงสร้าง ใช้ผิดไม่ได้กันได้แค่ re-render ของ component ที่ ถือ children เท่านั้น ไม่กัน ancestor ของมัน
ให้ React Compiler ทำแทนละเอียดกว่า sync กับ code เสมอต้อง setup toolchain ครอบคลุมแค่ code ที่ตรวจสอบแบบ static ได้
Manual memoization ทุกที่ไม่มีต้นทุนการเทียบ + memory ทุก render สำหรับ component ที่มักถูกอยู่แล้ว
  • “ห่อ component ด้วย React.memo ช่วยเสมอ” พิสูจน์แล้วด้านบน: ถ้า prop ไม่ stable ด้วย reference หรือ component ถูกอยู่แล้ว memo เพิ่มต้นทุนการเทียบโดยไม่ได้ประโยชน์อะไร — บางทีกลายเป็นต้นทุนสุทธิ
  • “useCallback อย่างเดียวหยุด child re-render ได้” พิสูจน์แล้วด้านบน: reference ที่ stable ไม่ทำอะไรเลยถ้า component ที่รับไม่ถูกห่อด้วย React.memo เพื่อเช็กมันจริง ๆ
  • “ส่ง children ลงมาเป็นแค่เรื่องสไตล์การเขียน” มันคือ optimization การ re-render ที่วัดได้จริง — พิสูจน์แล้วด้านบน — ที่ทำงานได้โดยไม่มี memoization API เลย เพราะ element reference ไม่เคยเปลี่ยนตั้งแต่แรก
  • “compiler ทำให้ทั้งหมดนี้ล้าสมัย เลยไม่ต้องเข้าใจมัน” compiler แปลงแค่ code ที่ตรวจสอบแบบ static ได้ว่าตาม Rules of React การเข้าใจว่า ทำไม memoization ถึงทำงาน ยังเป็นสิ่งที่บอกได้ว่า code ชิ้นนั้น compiler ช่วยได้อย่างปลอดภัยหรือเปล่า

เมื่อ review code ที่ generate มาที่ใช้ useMemo/useCallback/React.memo ให้เช็ก:

  • child ที่เป็น React.memo ได้รับ object, array หรือ function literal แบบ inline จาก caller ไหม? แบบนั้นทำลาย memoization ทุก render (พิสูจน์แล้วด้านบน)
  • useCallback ถูกใช้กับ callback ที่ส่งให้ component ที่ ไม่ได้ ห่อด้วย React.memo เองไหม? reference ที่ stable ไม่ทำอะไรตรงนั้นเลย
  • “expensive child” อาจ restructure เป็น children ที่ส่งลงมาจากที่สูงกว่าได้ไหม แทนที่จะหยิบ useMemo/React.memo มาใช้เลย?
  • React.memo ถูกใช้กับ component ที่ render ถูกอยู่แล้ว และ/หรือ prop ไม่มีทาง stable จริง — overhead ล้วน ๆ ไม่มีผลตอบแทน?
  • project มี React Compiler ตั้งค่าไว้ไหม หรือ code เขียน memoization มือที่ compiler จะจัดการได้ละเอียดกว่า? (ดู Memoization & the Compiler)

🐛 หา bug ในโค้ดนี้

const ExpensiveRow = React.memo(function ExpensiveRow({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});
function List({ items }) {
function handleSelect(id) {
console.log("selected", id);
}
return (
<ul>
{items.map((item) => (
<ExpensiveRow key={item.id} item={item} onSelect={handleSelect} />
))}
</ul>
);
}
เฉลย

มีสองปัญหาซ้อนกันตรงนี้ อย่างแรก onSelect={() => onSelect(item.id)} ข้างใน ExpensiveRow เองไม่เกี่ยวกับการเช็ก memo (มันรันหลังจากการเทียบ prop ผ่านหรือ fail ไปแล้ว) — แต่ handleSelect ที่ประกาศใหม่เป็น function สดในทุก render ของ List คือบั๊กตัวจริง: List ส่ง onSelect reference ใหม่เอี่ยมให้ทุก ExpensiveRow ทุก render shallow comparison ของ React.memo จึง fail ทุกครั้ง memo ไม่เคยคุ้มเลย พิสูจน์ด้วย pattern เดียวกันเป๊ะในเทสต์แรกของบทนี้ (renderCount 1 → 3 ไม่ใช่ 1 → 1) ทางแก้คือ const handleSelect = useCallback((id) => console.log("selected", id), []) ใน List คู่กับ ExpensiveRow ที่ memoize ไว้แล้ว — คู่กันตามที่บทนี้พิสูจน์ว่าจำเป็น

useCallback ทำอะไร?
ทำไมการส่ง inline style={{ color: "red" }} ให้ React.memo child ถึงทำลาย memo?
หลัง Parent render 3 ครั้ง renderCount ของ Row เป็นเท่าไร?
const Row = memo(function Row({ onSelect }) { renderCount++; return <li onClick={onSelect} />; });
function Parent() {
  const [, setTick] = useState(0);
  return <Row onSelect={() => setTick(t => t + 1)} />;
}
ถ้า callback ถูกห่อด้วย useCallback แต่ component ที่รับเป็น function component ธรรมดา (ไม่ห่อด้วย React.memo) re-render จะหยุดไหม?
ทำไมการส่ง component เข้ามาเป็น children ถึงกัน re-render ได้โดยไม่ต้องมี useMemo/useCallback/React.memo เลย?
เมื่อไรที่ห่อ component ด้วย React.memo มีโอกาสเป็นต้นทุนสุทธิมากที่สุด?