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

Rendering Performance

มีเหตุผลแค่ 3 อย่างที่ component จะ re-render:

  1. state ของตัวเองเปลี่ยน (มีการเรียก setter)
  2. parent re-render — ปกติแล้วเมื่อ component render, React จะ re-render children ทุกตัว
  3. 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"]
render กระจายลงไปทั้ง subtree

การ re-render ไม่ฟรี แต่ปกติแล้วถูก — อย่าลืมว่า reconciliation ให้ DOM change เป็นศูนย์ เมื่อ output ไม่เปลี่ยน ปัญหาจะเกิดก็ต่อเมื่อ subtree ขนาดใหญ่ หรือ component ที่แพง re-render โดยไม่จำเป็นและบ่อย

การแก้ที่ดีที่สุดคือเชิงโครงสร้าง ถ้า 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 เปลี่ยน

เมื่อย้าย 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 ด้วย (บทถัดไป)

อย่าเดาว่าจุดช้าอยู่ตรงไหน React มี component <Profiler> ที่สร้างมาเพื่องานนี้โดยเฉพาะ: ห่อ subtree ไหนก็ได้ด้วยมัน ส่ง onRender callback แล้ว React จะเรียกมันหลังทุก commit ใน subtree นั้นพร้อมตัวเลขที่คุณจะต้องเดาเอง — id, phase ("mount" ครั้งแรก, "update" ทุกครั้งหลังจากนั้น), actualDuration, baseDuration, startTime และ commitTime

src/__tests__/profilerMountUpdate.test.tsx
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 ที่คุณกำลังตามหาพอดี

<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 เดียวกับที่ onRender callback ได้รับ บวกกับ ทำไม มัน render (props เปลี่ยน, state เปลี่ยน, parent render, hooks เปลี่ยน)

ระหว่างสองอย่างนี้: ใช้ Highlight Updates ก่อนเพื่อจับว่า มี อะไร re-render มากเกินไป แล้วใช้ Profiler flamegraph เพื่อดูว่า render นั้น แพงแค่ไหน จริง ๆ ก่อนจะหยิบ React.memo มาใช้

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>
);
}
Babel 7.29.9 (jsdelivr) · React 19.3.0 dev build (esm.sh, pinned)
ComponentRendersLast 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 มาใช้

ทั้งสองเทคนิคทำให้ 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 แล้วค่อยตามทัน:

src/__tests__/deferredValueLag.test.tsx
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 จริง ๆ

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 ตัดออกได้

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 ต่ำ/localcomponent 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 ไปด้วยแต่ไม่ได้เสียต้นทุนอะไรเลย

เมื่อ 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)

เหตุผล 3 อย่างที่ component re-render คืออะไร?
เมื่อ parent re-render children ของมันจะเป็นยังไงโดย default?
อะไรคือการแก้ **แรก** ที่ดีที่สุดสำหรับ component ที่ re-render บ่อยเกินไป?
phases array หลัง render แรกและคลิกหนึ่งครั้งมีอะไรบ้าง?
const onRender: ProfilerOnRenderCallback = (id, phase) => {
  phases.push(phase);
};
render(<Profiler id="c" onRender={onRender}><Counter /></Profiler>);
// ...click the button once...
ตาม react.dev, timing ของ background re-render ของ useDeferredValue เป็นยังไง?
ลำดับ commit ของ (text, deferredText) ที่ test ในโพรบสังเกตได้คืออะไร?
function Demo() {
  const [text, setText] = useState("a");
  const deferredText = useDeferredValue(text);
  // ...
}
// setText("ab") is called via a fireEvent.change