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 }) { /* ... */ });ทำไม useCallback กับ React.memo เป็นคู่กัน
หัวข้อที่มีชื่อว่า “ทำไม useCallback กับ React.memo เป็นคู่กัน”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"]
// ❌ 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 ไม่ได้อะไรเลย
พิสูจน์: inline function ทำลาย React.memo; useCallback แก้ได้
หัวข้อที่มีชื่อว่า “พิสูจน์: inline function ทำลาย React.memo; useCallback แก้ได้”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)ลองดูของจริง: inline object เทียบ useMemo
หัวข้อที่มีชื่อว่า “ลองดูของจริง: inline object เทียบ useMemo”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> );}| Component | Renders | Last 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 อย่างเดียวไม่พอ — child ต้อง memoize ด้วย
หัวข้อที่มีชื่อว่า “useCallback อย่างเดียวไม่พอ — child ต้อง memoize ด้วย”useCallback ให้แค่ reference ที่ stable reference นั้นจะหยุด re-render ได้ก็ต่อเมื่อมีอะไรข้างล่างเช็ก equality กับมันจริง ๆ — ซึ่งแปลว่า component ที่รับมันต้องถูกห่อด้วย React.memo callback ที่ stable ที่ส่งให้ component ธรรมดาที่ไม่ memoize ไม่เปลี่ยนอะไรเลย: component นั้น re-render ทุกครั้งที่ parent re-render เหมือน child ทั่วไป ไม่ว่า prop มันจะ stable แค่ไหน
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 อีกตัวคือสิ่งที่ทำงานกับมันจริง ใช้แค่ตัวเดียวไม่ได้อะไรเลย
Children เป็น props: หยุด re-render โดยไม่ต้อง memoize อะไรเลย
หัวข้อที่มีชื่อว่า “Children เป็น props: หยุด re-render โดยไม่ต้อง memoize อะไรเลย”บทก่อนพูดถึง “ยก 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 เข้ามาเกี่ยวข้อง
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 สำหรับการคำนวณที่แพง — และให้ compiler ทำแทน
หัวข้อที่มีชื่อว่า “useMemo สำหรับการคำนวณที่แพง — และให้ compiler ทำแทน”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 ยังชี้ปัญหาอยู่
เมื่อไร manual memoization ยังช่วยจริง (วันนี้)
หัวข้อที่มีชื่อว่า “เมื่อไร manual memoization ยังช่วยจริง (วันนี้)”จนกว่า 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 กลายเป็นต้นทุนสุทธิ
หัวข้อที่มีชื่อว่า “เมื่อ React.memo กลายเป็นต้นทุนสุทธิ”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 — ไม่ใช่ใส่แบบอัตโนมัติกับทุกอย่าง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มองว่า 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 ช่วยได้อย่างปลอดภัยหรือเปล่า
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ 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 ไว้แล้ว — คู่กันตามที่บทนี้พิสูจน์ว่าจำเป็น