Rendering Performance
ทำไม component ถึง re-render
หัวข้อที่มีชื่อว่า “ทำไม component ถึง re-render”มีเหตุผลแค่ 3 อย่างที่ component จะ re-render:
- state ของตัวเองเปลี่ยน (มีการเรียก setter)
- parent re-render — ปกติแล้วเมื่อ component render, React จะ re-render children ทุกตัว
- context ที่มัน consume เปลี่ยนค่า
ข้อสองคือสิ่งที่คนแปลกใจ: parent re-render จะ re-render ทั้ง subtree ข้างล่าง แม้แต่ children ที่ prop ไม่เปลี่ยนเลย React ทำแบบนี้เพราะมันไม่รู้ได้เลยว่า output ของ child ขึ้นกับสิ่งที่เปลี่ยนไปหรือเปล่า ถ้าไม่เช็ก
flowchart TB app["App re-renders (state changed)"] --> header["Header re-renders"] app --> main["Main re-renders"] main --> list["List re-renders"] list --> item1["Item re-renders"] list --> item2["Item re-renders"] note["Every descendant re-renders, even if its props are unchanged"]
การ re-render ไม่ฟรี แต่ปกติแล้วถูก — อย่าลืมว่า reconciliation ให้ DOM change เป็นศูนย์ เมื่อ output ไม่เปลี่ยน ปัญหาจะเกิดก็ต่อเมื่อ subtree ขนาดใหญ่ หรือ component ที่แพง re-render โดยไม่จำเป็นและบ่อย
แก้ที่ 1: เก็บ state ให้ต่ำและ local
หัวข้อที่มีชื่อว่า “แก้ที่ 1: เก็บ state ให้ต่ำและ local”การแก้ที่ดีที่สุดคือเชิงโครงสร้าง ถ้า state ก้อนหนึ่งอยู่สูงใน tree แต่มีแค่ branch เล็ก ๆ เดียวที่ใช้มัน state เปลี่ยนทีก็ re-render ทั้ง tree ย้าย state ลงไป ที่ component ที่ใช้มันจริง ๆ ให้การเปลี่ยนแปลง re-render แค่ branch เล็ก ๆ นั้น
// ❌ Search text lives in App, so typing re-renders the entire app.function App() { const [query, setQuery] = useState(""); return ( <> <SearchBox query={query} onChange={setQuery} /> <HugeExpensiveDashboard /> {/* re-renders on every keystroke! */} </> );}
// ✅ Move the state into SearchBox; typing re-renders only SearchBox.function App() { return ( <> <SearchBox /> <HugeExpensiveDashboard /> {/* never re-renders while typing */} </> );}เทคนิคที่เกี่ยวข้อง: ยก content ขึ้นด้วย composition ถ้า parent ที่เปลี่ยนไวต้องห่อ child ที่ช้า ให้ส่ง child ตัวช้าเข้ามาเป็น children — children ที่ส่งมาเป็น prop จะไม่ re-render แค่เพราะ state ของ parent เปลี่ยน
แก้ที่ 2: หยุด propagation ด้วย React.memo
หัวข้อที่มีชื่อว่า “แก้ที่ 2: หยุด propagation ด้วย React.memo”เมื่อย้าย state ไม่ได้และ child นั้นแพงจริง ๆ ให้ห่อด้วย React.memo component ที่ memoize จะข้าม re-render เมื่อ props ของมัน shallow-equal กับ render ก่อนหน้า — มันกลายเป็นกำแพงที่ parent re-render ข้ามไม่ได้
const ExpensiveList = React.memo(function ExpensiveList({ items }) { // Only re-renders when `items` (by reference) actually changes. return items.map((it) => <Row key={it.id} item={it} />);});ข้อควรระวัง: React.memo ช่วยได้ก็ต่อเมื่อ props stable จริง ๆ ส่ง object, array หรือ function literal ใหม่เป็น prop แล้ว shallow comparison จะ fail เสมอ — นี่คือเหตุผลที่ memoization มักต้องการ useMemo/useCallback บน props ด้วย (บทถัดไป)
วัด ไม่ใช่เดา: <Profiler> API
หัวข้อที่มีชื่อว่า “วัด ไม่ใช่เดา: <Profiler> API”อย่าเดาว่าจุดช้าอยู่ตรงไหน React มี component <Profiler> ที่สร้างมาเพื่องานนี้โดยเฉพาะ: ห่อ subtree ไหนก็ได้ด้วยมัน ส่ง onRender callback แล้ว React จะเรียกมันหลังทุก commit ใน subtree นั้นพร้อมตัวเลขที่คุณจะต้องเดาเอง — id, phase ("mount" ครั้งแรก, "update" ทุกครั้งหลังจากนั้น), actualDuration, baseDuration, startTime และ commitTime
import { useState, Profiler, type ProfilerOnRenderCallback } from 'react';import { render, screen, act } from '@testing-library/react';
function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount((c) => c + 1)}>count: {count}</button>;}
test('Profiler onRender reports "mount" on first commit and "update" on the next', () => { const phases: string[] = []; const onRender: ProfilerOnRenderCallback = (_id, phase) => { phases.push(phase); };
render( <Profiler id="counter" onRender={onRender}> <Counter /> </Profiler>, ); expect(phases).toEqual(['mount']);
act(() => { screen.getByText(/count: 0/).click(); }); expect(phases).toEqual(['mount', 'update']);});
test('actualDuration is a non-negative number for every commit', () => { const durations: number[] = []; const onRender: ProfilerOnRenderCallback = (_id, _phase, actualDuration) => { durations.push(actualDuration); };
render( <Profiler id="counter2" onRender={onRender}> <Counter /> </Profiler>, ); act(() => { screen.getByText(/count: 0/).click(); });
expect(durations.length).toBe(2); for (const d of durations) expect(d).toBeGreaterThanOrEqual(0);});✓ Profiler onRender reports "mount" on first commit and "update" on the next✓ actualDuration is a non-negative number for every commit
Test Files 1 passed (1) Tests 2 passed (2)actualDuration คือเลขจริงที่ต้องจับตาในงาน diagnostics production: commit นี้ใช้เวลา render subtree จริงเท่าไร เทียบกับ baseDuration (เวลาที่ จะ ใช้ถ้า render ทั้ง subtree ใหม่หมดโดยไม่มี memoization เลย) component ที่ commit บ่อยด้วย actualDuration ที่ไม่น้อยและ phase เป็น "update" — ทั้งที่ prop ไม่เห็นว่าเปลี่ยน — คือ re-render ที่คุณกำลังตามหาพอดี
React DevTools: Highlight Updates และ Profiler flamegraph
หัวข้อที่มีชื่อว่า “React DevTools: Highlight Updates และ Profiler flamegraph”<Profiler onRender> ใช้เก็บตัวเลขแบบเขียนโปรแกรม สำหรับ session แบบ interactive React DevTools (browser extension) ให้ข้อมูลชุดเดียวกันแบบเห็นภาพ:
- “Highlight updates when components render” (checkbox ใน tab Components) จะ flash เส้นขอบสีรอบ component ที่เพิ่ง re-render — วิธีเร็วที่สุดที่จะ เห็น ปัญหา propagation จาก “แก้ที่ 2” ด้านบนด้วยตาตัวเอง สด ๆ บนหน้าจริง
- tab Profiler บันทึก session แล้ว render flamegraph ต่อ commit: แต่ละแท่งคือ component ความกว้างประมาณสัดส่วนกับเวลา render คลิกแท่งไหนก็เห็นตัวเลข
actualDuration/baseDurationเดียวกับที่onRendercallback ได้รับ บวกกับ ทำไม มัน render (props เปลี่ยน, state เปลี่ยน, parent render, hooks เปลี่ยน)
ระหว่างสองอย่างนี้: ใช้ Highlight Updates ก่อนเพื่อจับว่า มี อะไร re-render มากเกินไป แล้วใช้ Profiler flamegraph เพื่อดูว่า render นั้น แพงแค่ไหน จริง ๆ ก่อนจะหยิบ React.memo มาใช้
ลองดูของจริง: Profiler durations กับ useDeferredValue
หัวข้อที่มีชื่อว่า “ลองดูของจริง: Profiler durations กับ useDeferredValue”import { useState, useDeferredValue } from 'react';
function slowFilter(query: string): string[] { const items: string[] = []; for (let i = 0; i < 2000; i++) { if (String(i).includes(query)) items.push(String(i)); } return items;}
function ResultsList({ query }: { query: string }) { const results = slowFilter(query); return <p>{results.length} matches for "{query}"</p>;}
export default function App() { const [text, setText] = useState(''); const deferredText = useDeferredValue(text); return ( <div> <input value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)} placeholder="type digits, e.g. 12" /> <ResultsList query={deferredText} /> </div> );}| Component | Renders | Last commit (ms) |
|---|---|---|
| Click Run — commits will appear here. | ||
พิมพ์ตัวเลขในช่อง input ทีละตัว เปิดดู commit log: แต่ละ keystroke จะได้ commit “update” สองครั้ง — ครั้ง urgent (deferredText ยังลากตามหลังอยู่ ตรงตามที่ test ด้านบนพิสูจน์) แล้วตามด้วย background commit (deferredText ตามทัน) ResultsList ไม่ได้ memoize ไว้ จึงรัน filter loop ที่ตั้งใจให้ช้าใหม่ทั้งสอง commit — ดูแถวของมันในตารางเพิ่มทีละ 2 ต่อ keystroke และ duration ของทั้งสอง commit ก็ไม่น้อยเลย combination แบบนี้ — component ที่ re-render ทุก commit และ ทำงานจริงทุกครั้ง — คือสิ่งที่ <Profiler onRender> กับ DevTools flamegraph มีไว้เพื่อจับให้เห็น ก่อนจะหยิบ React.memo มาใช้
useDeferredValue เทียบ debounce
หัวข้อที่มีชื่อว่า “useDeferredValue เทียบ debounce”useDeferredValue เทียบ debounce
หัวข้อที่มีชื่อว่า “useDeferredValue เทียบ debounce”ทั้งสองเทคนิคทำให้ UI ตอบสนองไวไว้ในขณะที่ส่วนที่แพงลากตามหลัง — แต่ลากด้วยเหตุผลต่างกัน และความต่างนี้สำคัญเวลาเลือก
Debounce ดีเลย์การ apply ค่าจนกว่า input จะนิ่งเป็นเวลาคงที่ที่คุณเลือกเอง (setTimeout(..., 300) ตามปกติ) คุณกำลังเดาดีเลย์ที่นานพอจะข้ามงานที่เสียเปล่า แต่สั้นพอที่ยังรู้สึกตอบสนองไว
useDeferredValue ไม่มีดีเลย์คงที่เลย ตาม react.dev: “There is no fixed delay caused by useDeferredValue itself. As soon as React finishes the original re-render, React will immediately start working on the background re-render with the new deferred value.” background render ยัง interruptible ด้วย — ถ้ามี update ใหม่มาถึงค่าก่อนที่มันจะเสร็จ React จะทิ้ง background attempt ที่ค้างและเริ่มใหม่ด้วยค่าล่าสุด จึงไม่มีทางเสียเวลา render ผลที่ล้าสมัยไปแล้วแบบที่ debounce timer ทำได้
// Debounce: you choose the delay, and it fires on a timer regardless of render cost.function useDebounced(value, delayMs) { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(id); }, [value, delayMs]); return debounced;}
// useDeferredValue: no delay to tune, no timer, no cleanup — React// schedules the background render at low priority and adapts automatically.const deferredQuery = useDeferredValue(query);พิสูจน์ว่า deferred value ลากตามหลัง urgent input จริงหนึ่ง render แล้วค่อยตามทัน:
import { useState, useDeferredValue } from 'react';import { render, screen, fireEvent, act } from '@testing-library/react';
function Demo({ onCommit }: { onCommit: (text: string, deferred: string) => void }) { const [text, setText] = useState('a'); const deferredText = useDeferredValue(text); onCommit(text, deferredText); return ( <div> <input aria-label="urgent" value={text} onChange={(e) => setText(e.target.value)} /> <p data-testid="deferred">{deferredText}</p> </div> );}
test('useDeferredValue commits an intermediate render with the OLD value before catching up to the new one', () => { const commits: Array<{ text: string; deferred: string }> = []; const onCommit = (text: string, deferred: string) => commits.push({ text, deferred });
render(<Demo onCommit={onCommit} />);
act(() => { fireEvent.change(screen.getByLabelText('urgent'), { target: { value: 'ab' } }); });
// react.dev: "React will first attempt a re-render with the old value, and // then try another re-render in the background with the new value." expect(commits).toEqual([ { text: 'a', deferred: 'a' }, // mount { text: 'ab', deferred: 'a' }, // urgent update commits; deferred still lags { text: 'ab', deferred: 'ab' }, // background render catches up ]);
expect(screen.getByLabelText<HTMLInputElement>('urgent').value).toBe('ab'); expect(screen.getByTestId('deferred').textContent).toBe('ab');});✓ useDeferredValue commits an intermediate render with the OLD value before catching up to the new one
Test Files 1 passed (1) Tests 1 passed (1)หยิบ useDeferredValue มาใช้เมื่อคุณมีแค่ value (ไม่มี setter ให้ห่อใน startTransition เช่นมันมาเป็น prop) หยิบ debounce มาใช้เมื่อต้องการ throttle อัตราการเรียก จริง ๆ — เช่น network request ทุก keystroke ที่ “เร็วที่สุดเท่าที่จะทำได้” เป็นเป้าหมายที่ผิด และคุณต้องการรอ pause จริง ๆ
เมื่อตัว render เองยังเป็นปัญหา: virtualization
หัวข้อที่มีชื่อว่า “เมื่อตัว render เองยังเป็นปัญหา: virtualization”Memoization และ React.memo หยุด re-render ที่ ไม่จำเป็น แต่ไม่ช่วยเลยเมื่อ render เดียวที่จำเป็นต้องสร้าง DOM node จริงเป็นพัน ๆ — ตาราง 10,000 แถว render ช้าตั้งแต่ครั้งแรกไม่ว่าจะ memoize ดีแค่ไหนหลังจากนั้น ทางแก้สำหรับ list ที่ใหญ่จริง ๆ คือ virtualization: render เฉพาะแถวที่ scroll เข้ามาในจอ (บวก buffer เล็กน้อย) แล้วสลับตอนผู้ใช้ scroll เพื่อให้ DOM มีแค่ไม่กี่สิบ node เสมอไม่ว่า data จะใหญ่แค่ไหน คอร์สนี้ไม่ได้เพิ่ม library virtualization เป็น dependency แต่ tool มาตรฐานตอนนี้คือ @tanstack/virtual — หยิบมาใช้เมื่อ profile แล้วเห็นว่าต้นทุนแปรผันตามความยาว list ไม่ใช่อะไรที่ memoization ตัดออกได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”re-render ไม่ใช่ “output ของ component นี้เปลี่ยนไหม” — มันคือ “React รัน function ของ component นี้ใหม่ไหม” ปกติแล้วมันรันทั้ง subtree ใหม่เมื่อ state เปลี่ยน แล้ว ค่อย เช็ก (ผ่าน reconciliation) ว่า DOM ต้องเปลี่ยนหรือเปล่า React.memo แทรก checkpoint ก่อน การรันใหม่นั้น: “props shallow-equal กับครั้งก่อนไหม ถ้าใช่ ข้ามการรัน function นี้ไปเลย” Profiler API และ DevTools มีไว้เพื่อให้คุณไม่ต้องเดาเลยว่าสองอย่างนี้ — การรัน function หรือ การ commit DOM change — อันไหนกินเวลาจริง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
| เก็บ state ต่ำ/local | component re-render น้อยลงต่อการเปลี่ยนแปลงหนึ่งครั้ง ไม่ต้องมี API เพิ่ม | ต้องสังเกตว่า state อยู่ตรงไหนจริง บางครั้งต้อง restructure component |
React.memo | กั้น subtree ที่แพงจาก re-render ของ parent | ใช้ได้ก็ต่อเมื่อทุก prop stable ด้วย reference; เพิ่มการเทียบทุก render |
useDeferredValue | ไม่มีดีเลย์ต้องปรับ, interruptible อัตโนมัติ, integrate กับ Suspense | ไม่มี flag แบบ isPending; ต้องเทียบ value !== deferredValue เอง |
| Debounce | ง่าย เข้าใจกันดีทั่วไป ดีสำหรับลด อัตรา การเรียก (เช่น network request) | ต้องเดาดีเลย์คงที่ เสียดีเลย์ไปเปล่า ๆ แม้ render จะถูกอยู่แล้ว |
| Virtualization | ขนาด DOM คงที่ไม่ว่า list จะยาวแค่ไหน | เพิ่ม dependency และความซับซ้อน ทำลายสมมติฐานเช่น “select-all” text หรือ Ctrl+F ข้ามทั้ง list |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “re-render แปลว่า DOM เปลี่ยน” reconciliation ยังรัน diff อยู่ดี; re-render ที่ output เหมือนเดิมให้ DOM mutation เป็นศูนย์ ต้นทุนคือการรัน function ใหม่และ diff ไม่ใช่การแตะ browser จริง ๆ เสมอไป
- “
useDeferredValueดีเลย์การ update ไปเวลาหนึ่ง เหมือน debounce” พิสูจน์แล้วด้านบน: ไม่มี timer มัน commit render ด้วยค่าเก่าทันที แล้วเริ่มค่าใหม่ทันทีที่ครั้งแรกเสร็จ — ไม่ใช่หลังรอที่กำหนดตายตัว - “
React.memoทำให้ component เร็ว” มันแค่ข้ามการ รัน function ใหม่เมื่อ props ไม่เปลี่ยน มันไม่ช่วยอะไรเลยกับ component ที่ render เดียวที่จำเป็นแพงอยู่แล้ว (นั่นคือหน้าที่ของ virtualization หรือแก้ algorithm) - “ต้อง profile ทั้งแอปเพื่อหาปัญหา” profile เฉพาะ interaction ที่รู้สึกช้า (React DevTools Profiler บันทึก session) — profile ทั้งหมดจะฝัง component ตัวเดียวที่สำคัญไว้ใต้ทุกอย่างที่ re-render ไปด้วยแต่ไม่ได้เสียต้นทุนอะไรเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ review โค้ดที่ generate มาที่แตะเรื่อง rendering performance ให้เช็ก:
React.memoถูกใช้กับ component ที่ parent ยังส่ง object, array หรือ function literal ใหม่ทุก render อยู่ไหม? memo ไม่ช่วยอะไรเลยในกรณีนั้น (พิสูจน์ในบทถัดไป)- “debounced search” ใช้
setTimeoutคงที่จริง ๆ ทั้งที่useDeferredValueจะปรับตามต้นทุน render จริงเองได้ — หรือกลับกันuseDeferredValueถูกใช้ throttle network call จริง (มันไม่ลดจำนวนครั้งที่เรียก ลดแค่ priority ของ render)? - state ที่มีแค่ leaf component เล็ก ๆ ตัวเดียวอ่าน ถูกประกาศไว้สูงใน tree “เผื่อไว้” จนทำให้ sibling ที่ไม่เกี่ยวข้อง re-render ทุกครั้งที่เปลี่ยนไหม?
- claim ว่า “component นี้ไม่ค่อย re-render” มีหลักฐานจาก Profiler หรือ DevTools รองรับไหม หรือแค่อ่านจาก code แล้วสรุปเอง?
- list ขนาดใหญ่คงที่ (data เป็นพัน) ถูก render เต็มโดยไม่มี virtualization แล้วใช้ memoization มากลบเกลื่อน render ที่แพงโดยธรรมชาติอยู่แล้วไหม?
🐛 หา bug ในโค้ดนี้
const Row = React.memo(function Row({ label, onSelect }) {return <li onClick={onSelect}>{label}</li>;});
function List({ items, onPick }) {return ( <ul> {items.map((item) => ( <Row key={item.id} label={item.label} onSelect={() => onPick(item.id)} /> ))} </ul>);}เฉลย
Row ถูกห่อด้วย React.memo แต่ List ส่ง onSelect={() => onPick(item.id)} — arrow function ใหม่ที่สร้างทุก render ของ List ต่อทุกแถว shallow comparison ของ React.memo เห็น onSelect ที่ “เปลี่ยน” ทุกครั้ง Row เลย re-render ทุกครั้งที่ List re-render เหมือน memo ไม่มีอยู่เลย ทางแก้คือให้ Row ได้ callback ที่ stable: ส่ง item.id ลงไปแล้วเรียก onPick(id) ข้างใน Row เอง (ทำให้ List ส่งแค่ onPick reference ที่ stable) หรือห่อ callback ต่อแถวด้วย useCallback ที่ key ด้วย item.id (คุ้มก็ต่อเมื่อ onPick เองก็ stable)