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

Rendering & Reconciliation

ความเข้าใจผิดที่พบบ่อย: “re-render” แปลว่า React แตะ DOM ไม่ใช่ React แบ่งงานเป็นสอง phase ที่ต่างกันชัดเจน:

flowchart LR
  trigger["state change /
parent re-render"] --> render["Render phase
(call components, build new tree)
— pure, no DOM"]
  render --> recon["Reconciliation
(diff new tree vs old)"]
  recon --> commit["Commit phase
(apply minimal DOM changes)
— side effects here"]
สอง phase ของการ update
  1. Render phase — React เรียก component ของคุณเพื่อสร้าง tree ของ element ใหม่ นี่คือการคำนวณล้วน ๆ: ไม่แตะ DOM และ React หยุดหรือทิ้งงานนี้ได้
  2. Commit phase — React เทียบ tree ใหม่กับอันก่อนหน้า คำนวณชุดการแก้ DOM ที่ น้อยที่สุด แล้ว apply

ดังนั้น re-render ที่ให้ output เหมือนเดิมจะทำให้ DOM เปลี่ยน ศูนย์ จุด การ render ค่อนข้างถูก ส่วนที่แพง (แตะ DOM) เกิดเฉพาะกับสิ่งที่เปลี่ยนจริงเท่านั้น (React จะ schedule และสลับงานนี้ อย่างไร จริง ๆ — fiber, lane, work loop — อยู่ในโมดูล internals)

การเทียบ tree ใด ๆ สองอันขึ้นชื่อว่าแพง (O(n³)) React ทำให้เร็ว (O(n)) ด้วย heuristic สองข้อ และทั้งคู่พิสูจน์ได้จริง ไม่ใช่แค่คำอธิบาย:

  • type ต่างกัน → ทิ้ง ถ้า type ของ element เปลี่ยน (<div> กลายเป็น <span> หรือ ComponentA กลายเป็น ComponentB) React จะ unmount subtree เก่าทั้งอันแล้วสร้างใหม่ทั้งหมด — รวมถึงทำลาย state ของตัวเองด้วย
  • type เดียวกัน → เก็บ node ไว้ update props ถ้า type เหมือนเดิม React เก็บ DOM node เดิมและ state ของตัวเองไว้ แล้ว update แค่ props ที่เปลี่ยน — ไม่มี mount/unmount เลย
src/__tests__/reconciliation-rules.test.tsx
import { useEffect } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('a different element type unmounts the old subtree and mounts a new one', () => {
const log: string[] = [];
function Field({ as }: { as: 'input' | 'p' }) {
useEffect(() => {
log.push(`mount:${as}`);
return () => { log.push(`unmount:${as}`); };
}, [as]);
return as === 'input' ? <input defaultValue="hi" /> : <p>hi</p>;
}
const { rerender } = render(<Field as="input" />);
log.length = 0;
rerender(<Field as="p" />); // type changed: input -> p
expect(log).toEqual(['unmount:input', 'mount:p']);
});
test('the same element type across renders updates props in place — no remount', () => {
const log: string[] = [];
function Row({ disabled }: { disabled: boolean }) {
useEffect(() => {
log.push('mount');
return () => { log.push('unmount'); };
}, []);
return <input disabled={disabled} />;
}
const { rerender } = render(<Row disabled={false} />);
log.length = 0;
rerender(<Row disabled={true} />); // same type: input -> input
expect(log).toEqual([]); // no mount/unmount — only the prop changed
});
✓ a different element type unmounts the old subtree and mounts a new one
✓ the same element type across renders updates props in place — no remount
Test Files 1 passed (1)
Tests 2 passed (2)
// Same type across renders → the <input> keeps its DOM node and its focus/value.
{isEditing ? <input value={text} /> : <input value={text} disabled />}
// Different type → React destroys the first and mounts the second (state lost).
{isEditing ? <input value={text} /> : <p>{text}</p>}

สำหรับ list ของ sibling ชนิดเดียวกัน React ต้องรู้ว่าไอเทมไหนเป็นไอเทมไหนข้าม render โดย default React จับคู่ด้วย ตำแหน่ง (index) — ซึ่งพังทันทีที่ไอเทม reorder แทรก หรือลบ key ให้ identity ที่คงที่กับแต่ละไอเทม เพื่อให้ React ติดตามแต่ละไอเทมได้ แทนที่จะติดตามช่องที่มันบังเอิญนั่งอยู่

src/__tests__/position-vs-key.test.tsx
import { useState } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
type Item = { id: string; text: string };
function Checkbox({ label }: { label: string }) {
const [checked, setChecked] = useState(false);
return (
<label>
<input type="checkbox" checked={checked} onChange={() => setChecked((c) => !c)} />
{label}
</label>
);
}
function List({ items, useIndexKey }: { items: Item[]; useIndexKey: boolean }) {
return (
<ul>
{items.map((item, i) => (
<li key={useIndexKey ? i : item.id}>
<Checkbox label={item.text} />
</li>
))}
</ul>
);
}
test('index-as-key: prepending an item moves the checked state to the WRONG row', () => {
const items: Item[] = [{ id: 'a', text: 'Alpha' }, { id: 'b', text: 'Bravo' }];
const { getAllByRole, rerender } = render(<List items={items} useIndexKey />);
fireEvent.click(getAllByRole('checkbox')[0]); // check "Alpha" (currently index 0)
const withPrepend = [{ id: 'z', text: 'Zulu' }, ...items];
rerender(<List items={withPrepend} useIndexKey />);
const checked = getAllByRole('checkbox').map((c) => (c as HTMLInputElement).checked);
// BUG: index 0 is now "Zulu", but the checked=true STATE stayed pinned to
// position 0 — Zulu shows checked, not Alpha.
expect(checked).toEqual([true, false, false]);
});
test('stable id-as-key: the same reorder keeps the checked state on the correct item', () => {
const items: Item[] = [{ id: 'a', text: 'Alpha' }, { id: 'b', text: 'Bravo' }];
const { getAllByRole, rerender, getByText } = render(<List items={items} useIndexKey={false} />);
fireEvent.click(getAllByRole('checkbox')[0]);
const withPrepend = [{ id: 'z', text: 'Zulu' }, ...items];
rerender(<List items={withPrepend} useIndexKey={false} />);
const alphaCheckbox = getByText('Alpha').closest('label')!.querySelector('input') as HTMLInputElement;
const zuluCheckbox = getByText('Zulu').closest('label')!.querySelector('input') as HTMLInputElement;
expect(alphaCheckbox.checked).toBe(true); // followed Alpha, wherever it moved
expect(zuluCheckbox.checked).toBe(false);
});
✓ index-as-key: prepending an item moves the checked state to the WRONG row
✓ stable id-as-key: the same reorder keeps the checked state on the correct item
Test Files 1 passed (1)
Tests 2 passed (2)

กฎ: key ต้อง คงที่ ไม่ซ้ำในกลุ่ม sibling และผูกกับ identity ของข้อมูล (ปกติคือ database id) — อย่าใช้ array index สำหรับ list ที่เปลี่ยนได้ และอย่าใช้ Math.random() (ซึ่งเปลี่ยนทุก render และบังคับให้ remount ทุกไอเทมทุกครั้ง)

key ในฐานะ reset switch (การใช้งานอีกแบบของกลไกเดียวกัน)

หัวข้อที่มีชื่อว่า “key ในฐานะ reset switch (การใช้งานอีกแบบของกลไกเดียวกัน)”

list identity เป็นงานหนึ่งของ key — แต่กฎเดิม “key เดิม → instance เดิม, key ต่าง → remount” ก็เป็นเครื่องมือที่ตั้งใจใช้ได้เองด้วย ไม่เกี่ยวกับ list เลย: ใส่ key ที่เปลี่ยนได้ชัดเจนบน component เดี่ยว ตัวเดียว เมื่อไรก็ตามที่คุณอยากให้ React ทิ้ง state ทั้งหมดของมันแล้วเริ่มใหม่ แทนที่จะ update มันในที่เดิม

src/__tests__/key-as-reset-switch.test.tsx
import { useState, useEffect } from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('changing key remounts the component and resets its internal state, even with the same props', () => {
const log: string[] = [];
function Draft({ label }: { label: string }) {
const [count, setCount] = useState(0);
useEffect(() => {
log.push(`mount:${label}`);
return () => { log.push(`unmount:${label}`); };
}, [label]);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}
const { getByRole, rerender } = render(<Draft key="a" label="a" />);
fireEvent.click(getByRole('button'));
expect(getByRole('button').textContent).toBe('1');
log.length = 0;
rerender(<Draft key="b" label="a" />); // key changes even though `label` prop did NOT
expect(log).toEqual(['unmount:a', 'mount:a']); // forced remount
expect(getByRole('button').textContent).toBe('0'); // internal state reset
});
✓ changing key remounts the component and resets its internal state, even with the same props
Test Files 1 passed (1)
Tests 1 passed (1)

นี่คือทางแก้มาตรฐานของ “form/editor นี้ยังโชว์ state เก่าค้างอยู่หลังจากฉันเปลี่ยนสิ่งที่มันกำลังแก้ไข” — ใส่ key={editingItemId} บน component แล้วการเปลี่ยน id จะบังคับ remount แบบสะอาด แทนที่จะให้ component แบก internal state เก่าจาก id ก่อนหน้ามาด้วย

flowchart TB
  before["Before: [A, B, C]
keys a,b,c"] -->|prepend Z| after["After: [Z, A, B, C]
keys z,a,b,c"]
  after --> good["With stable keys:
A,B,C reused, Z mounted"]
  before2["Before: [A, B, C]
key = index 0,1,2"] -->|prepend Z| after2["After: index 0,1,2,3"]
  after2 --> bad["With index keys:
every item mismatched,
state shifts to wrong rows"]
  same["Same component, key changes
(e.g. key={itemId})"] --> reset["Forced unmount + remount:
state resets on purpose"]
key ทำให้ reconciliation ติดตามไอเทมข้ามการ reorder — หรือบังคับ reset
import { useState, memo } from 'react';
const MemoChild = memo(function MemoChild({ label }: { label: string }) {
return <li>Memo child — stays put unless its own props change: {label}</li>;
});
function Draft({ resetKey }: { resetKey: number }) {
const [text, setText] = useState('');
return (
<div>
<p>Draft (key={resetKey}) — internal state below resets when you click "New draft":</p>
<input value={text} onChange={(e) => setText((e.target as HTMLInputElement).value)} placeholder="type here, then click New draft" />
</div>
);
}
export default function App() {
const [tick, setTick] = useState(0);
const [resetKey, setResetKey] = useState(0);
return (
<div>
<button onClick={() => setTick((t) => t + 1)}>Parent tick: {tick}</button>
<ul>
<li>Parent tick value: {tick}</li>
<MemoChild label="stable" />
</ul>
<button onClick={() => setResetKey((k) => k + 1)}>New draft (remount via key)</button>
<Draft key={resetKey} resetKey={resetKey} />
</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 ไปเรื่อย ๆ: แถว App ในตารางจะเพิ่มขึ้น แต่แถว MemoChild จะค้างที่ 1 — เพราะ prop เดียวของมันไม่เคยเปลี่ยน React.memo เลยข้ามการเรียก function นี้ไปเลย (ตามที่พิสูจน์ด้วย test ด้านบน) แล้วลองคลิก New draft (remount via key): แถว Draft จะเพิ่มทีละ 1 พอดีต่อคลิก และข้อความที่พิมพ์ไว้จะหายทุกครั้ง เพราะ key ที่เปลี่ยนบังคับ unmount + remount จริง ไม่ใช่แค่ update — “reset switch” ตัวเดียวกับที่พิสูจน์ไว้ข้างบน

ตอน development <StrictMode> ตั้งใจ render component ของคุณสองครั้ง และรัน effect setup → cleanup → setup หนึ่งรอบตอน mount นี่ไม่ใช่ bug — มัน simulate สิ่งที่จะเกิดถ้า React ต้องทิ้งแล้วนำ state ของ component กลับมาใช้ทีหลัง (การ optimize ที่ concurrent React พึ่งพา) ดังนั้น render หรือ effect ไหนที่ทำซ้ำได้ไม่ปลอดภัยจะโผล่ออกมาทันที ตอน dev แทนที่จะไปทำลาย state แบบเงียบ ๆ ตอน production

src/__tests__/strictmode-double-render-effect.test.tsx
import { StrictMode, useEffect } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('StrictMode (dev) renders twice and runs setup -> cleanup -> setup once on mount', () => {
let renders = 0;
const effectLog: string[] = [];
function Probe() {
renders++;
useEffect(() => {
effectLog.push('setup');
return () => { effectLog.push('cleanup'); };
}, []);
return null;
}
render(
<StrictMode>
<Probe />
</StrictMode>,
);
expect(renders).toBe(2);
expect(effectLog).toEqual(['setup', 'cleanup', 'setup']);
});
test('without StrictMode, the same component renders and effects run exactly once', () => {
let renders = 0;
const effectLog: string[] = [];
function Probe() {
renders++;
useEffect(() => {
effectLog.push('setup');
return () => { effectLog.push('cleanup'); };
}, []);
return null;
}
render(<Probe />);
expect(renders).toBe(1);
expect(effectLog).toEqual(['setup']);
});
✓ StrictMode (dev) renders twice and runs setup -> cleanup -> setup once on mount
✓ without StrictMode, the same component renders and effects run exactly once
Test Files 1 passed (1)
Tests 2 passed (2)

ถ้า render ของคุณ mutate อะไรนอกตัวมันเอง หรือ cleanup ของ effect แก้ setup ของมันไม่หมด รอบที่สองจะเผยมันออกมาทันที (จำนวนผิด, subscription ซ้ำซ้อน, timer ที่รั่ว) — นั่นคือประเด็นทั้งหมดของ double-invoke และมันเกิดแค่ตอน development เท่านั้น

งานทั้งหมดของ reconciliation คือตอบคำถามเดียวต่อ element หนึ่งตัว ทุก render: “นี่คือของเดิม หรือของใหม่?” type ตอบคำถามนี้สำหรับตำแหน่งเดียว (type เดิม = ของเดิม เก็บ instance ไว้; type ต่าง = ของใหม่ ทิ้งแล้วสร้างใหม่) key ตอบคำถามนี้สำหรับ identity ที่ต้องรอดจาก การเคลื่อนที่ — ไอเทมใน list ที่เปลี่ยนตำแหน่ง หรือ component ที่คุณตั้งใจอยากให้ถือว่าเป็น “ของใหม่” ทั้งที่ type และ props หน้าตาคล้ายเดิม พอมองว่า key กับ type เป็น input สองตัวของคำถาม “เดิมหรือใหม่” เดียวกัน “key สำหรับ list identity” กับ “key เป็น reset switch” ก็จะไม่ดูเหมือนสอง feature ที่แยกกันอีกต่อไป

ทางเลือกประโยชน์ต้นทุน
Stable id เป็น keyถูกต้องข้าม reorder/insert/delete — state ตามแถวที่ถูกต้องต้องมี stable id จริงในข้อมูล ไม่ได้มีให้ฟรีเสมอไป
Index เป็น keyไม่ต้องมีข้อมูลเพิ่ม “ใช้ได้เลย” สำหรับ list ที่ไม่มีวัน reorderทำลาย per-item state แบบเงียบ ๆ ทันทีที่ list สามารถ reorder, insert, หรือ delete ได้
key={changingToken} reset switchreset state ของทั้ง subtree แบบสะอาดและรับประกัน — ถูกกว่าการ reset ทีละ state ด้วยมือunmount/remount เต็มรูปแบบแพงกว่าการ update ในที่เดิม; effect รัน setup ใหม่ทั้งหมด
StrictMode double render/effectเผย render/effect ที่ไม่ปลอดภัยตอน dev ก่อนจะไปถึง productionเพิ่ม cost ของ render/effect เป็นสองเท่าตอน dev (dev เท่านั้น — production รันครั้งเดียว)
  • “re-render แตะ DOM เสมอ” ไม่จริง — render คือการสร้าง tree ล้วน ๆ มีแค่ commit phase เท่านั้นที่แตะ DOM และแตะแค่ส่วนที่เปลี่ยนจริง (พิสูจน์แล้วข้างบน: re-render ที่เหมือนเดิมเป๊ะ ไม่ commit อะไรเลย)
  • “index key โอเคตราบใดที่ list สั้น” ความยาวไม่เกี่ยว — บั๊กคือ state เลื่อนไปแถวผิดทันทีที่ list reorder/insert/delete พิสูจน์แล้วข้างบนไม่ว่า list จะยาวแค่ไหน
  • “key มีไว้สำหรับ list เท่านั้น” มันเป็นสัญญาณ “instance เดิมหรือใหม่” แบบทั่วไป — การใส่ key ที่เปลี่ยนได้บน component เดี่ยวที่ไม่ใช่ list ก็เป็นวิธีมาตรฐานที่ตั้งใจใช้บังคับ reset เต็มรูปแบบ พิสูจน์แล้วข้างบน
  • “double render ของ StrictMode แปลว่า component ของฉันรันสองครั้งใน production ด้วย” มันมีแค่ตอน development พิสูจน์แล้วข้างบน (test ที่สองไม่มี StrictMode render/effect รันแค่ครั้งเดียวเป๊ะ) — production รันครั้งเดียวเสมอ

เมื่อโค้ดที่ generate มา render list หรือพึ่งพา component identity ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • .map() บน list ใช้ array index เป็น key สำหรับ list ที่ reorder, filter, หรือแทรก/ลบไอเทมได้ไหม? นั่นคือบั๊ก state-bleed คลาสสิกที่พิสูจน์แล้วข้างบน
  • มันใช้ Math.random() หรือ Date.now() เป็น key ไหม? ทั้งคู่เปลี่ยนทุก render บังคับ remount ทุกไอเทมทุกครั้ง — ไม่ใช่ทางแก้จริงของ “React บ่นเรื่อง key หาย”
  • component editor/form ยังโชว์ข้อมูลเก่าค้างหลัง prop “current item” เปลี่ยนไหม ทั้งที่ key={currentItemId} reset จะแก้ได้สะอาดกว่าการ reset ทีละ internal state ด้วยมือ?
  • conditional render สลับระหว่าง element/component type ต่างกันไหม (เช่น isLoading ? <Spinner/> : <Form/>) โดยผู้เขียนสมมติว่า state จะรอดข้ามการสลับ? type ต่างกัน remount เสมอ — state ใด ๆ ใน subtree เก่าหายไปหมด
  • โค้ด test หรือ demo สมมติว่า effect ของ component รันแค่ครั้งเดียว โดยไม่คิดถึง double-invoke ของ StrictMode ที่มีแค่ตอน dev (ซึ่งทำให้ effect ที่เขียนไม่ระวังดูเหมือน “ยิงซ้ำ” network call หรือ subscription) ไหม?

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

function TodoList({ todos }) {
return (
<ul>
{todos.map((todo, i) => (
<li key={i}>
<input type="checkbox" defaultChecked={todo.done} />
{todo.text}
</li>
))}
</ul>
);
}
เฉลย

key={i} ใช้ array index เป็น identity ตราบใดที่ list ไม่เคย reorder หน้าตาก็ดูโอเค — แต่ทันทีที่มีไอเทมถูกแทรก ลบ หรือ list ถูก sort React จะจับคู่ state ของ checkbox เก่าเข้ากับไอเทมที่บังเอิญมานั่งตำแหน่งเดิม ไม่ใช่ไอเทมที่มันเคยถูก check ไว้ พิสูจน์แล้วข้างบน: check แถวแรกแล้ว prepend ไอเทมใหม่ ทำให้ checked state ค้างอยู่ที่ตำแหน่ง 0 (ตอนนี้เป็นคนละ todo แล้ว) แทนที่จะตาม todo ที่ user check ไว้จริง ทางแก้คือ key={todo.id} (stable id จากข้อมูล) ซึ่งทำให้ state ของแต่ละ checkbox ติดกับ todo ที่ถูกต้องข้ามการ reorder ใด ๆ

ระหว่าง "render phase" เกิดอะไรขึ้น?
ระหว่าง reconciliation React ทำอะไรเมื่อ type ของ element เหมือนเดิม?
ทำไมการใช้ array index เป็น key ถึงเสี่ยงสำหรับ list ที่ reorder ได้?
ถ้า re-render ให้ output เหมือนเดิมเป๊ะ DOM เปลี่ยนกี่จุด?
หลังคลิกปุ่มครั้งเดียว (count เป็น 1) แล้ว re-render ด้วย key ใหม่ แต่ label *เหมือนเดิม* ปุ่มจะโชว์อะไร?
function Draft({ label }: { label: string }) {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// rendered first as <Draft key="a" label="a" />, then as <Draft key="b" label="a" />
mount เป็น <StrictMode><Probe /></StrictMode> ตอน development log จะพิมพ์ลำดับไหน และ Probe render กี่ครั้ง?
function Probe() {
  useEffect(() => {
    console.log('setup');
    return () => console.log('cleanup');
  }, []);
  return null;
}