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

Reconciliation in Depth

Rendering & Reconciliation พิสูจน์พฤติกรรมจากภายนอกไปแล้ว: type เดียวกัน update ในที่เดิม, type ต่างกัน remount, และ key ที่คงที่ทำให้ state ตามไอเทมที่ถูกต้องใน list บทนี้พูดถึงโค้ดจริงใน packages/react-reconciler/src/ReactChildFiber.js ที่ผลิตผลลัพธ์เหล่านั้น — และมันคือสอง code path ที่แตกต่างกันจริง ๆ เพราะ “child ตัวเดียว” กับ “list ของ child” ต้องใช้กลยุทธ์คนละแบบ

สำหรับ child ที่เป็น element เดียว reconcileSingleElement แค่ต้องเทียบกับ fiber ตัว เดียว ที่นั่งอยู่ตรงนั้นแล้ว:

// packages/react-reconciler/src/ReactChildFiber.js — reconcileSingleElement (trimmed)
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (child.elementType === elementType /* ... */) {
// Same key AND same type: delete any other leftover children,
// reuse this fiber, update its props.
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.return = returnFiber;
return existing;
}
// Same key, DIFFERENT type: this key is claimed by something else now.
// Delete this fiber and everything after it, fall through to create fresh.
deleteRemainingChildren(returnFiber, child);
break;
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// No match found — create a brand new fiber.
// ...
}

อ่าน branch กลางนั้นดี ๆ: key ตรงกันอย่างเดียวไม่พอ React เช็ก child.elementType === elementType — reference equality ตรง ๆ บน type — เพิ่มเติมจาก การเทียบ key key เดียวกันแต่ type ต่างกันยังคง delete แล้วสร้างใหม่อยู่ดี นี่คือกลไกเป๊ะ ๆ ที่อยู่หลังบั๊กคลาสสิก “ฉันใส่ key แล้ว ทำไมมัน remount อยู่ดี” และมัน test ได้ตรง ๆ:

src/__tests__/type-identity-remount.test.tsx
import { useEffect } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('a component function defined INSIDE another component\'s render body remounts every render, even with a stable key', () => {
const log: string[] = [];
function Parent({ tick }: { tick: number }) {
void tick; // re-rendered with a new value each time, to force Parent to re-render
// A new function object every call -> a new `type` every call.
function Inner() {
useEffect(() => {
log.push('mount');
return () => { log.push('unmount'); };
}, []);
return <p>inner</p>;
}
return <Inner key="looks-stable" />;
}
const { rerender } = render(<Parent tick={0} />);
log.length = 0;
rerender(<Parent tick={1} />); // same key, but Inner is a NEW function this time
expect(log).toEqual(['unmount', 'mount']); // full remount, per reconcileSingleElement above
});
✓ a component function defined INSIDE another component's render body remounts every render, even with a stable key
Test Files 1 passed (1)
Tests 1 passed (1)

list ยากกว่า: ไอเทมถูกเพิ่ม ลบ และ reorder พร้อมกันได้หมด และการเทียบ child เก่าทุกตัวกับ child ใหม่ทุกตัวแบบไร้เดียงสาจะเป็น O(n²) reconcileChildrenArray หลีกเลี่ยงสิ่งนั้นด้วยกลยุทธ์สองรอบเฉพาะ รอบแรกมันเดิน child เก่ากับใหม่ พร้อมกัน ตามตำแหน่ง ตราบใดที่ key ยังตรงกัน (fast path สำหรับ “ไม่มีอะไร reorder”) ทันทีที่ key ไม่ตรง มันหยุด fast walk นั้นแล้วสร้าง lookup table จาก child เก่าที่เหลืออยู่ — นี่คือ mapRemainingChildren:

// packages/react-reconciler/src/ReactChildFiber.js — mapRemainingChildren (trimmed)
function mapRemainingChildren(currentFirstChild) {
// Add the remaining children to a temporary map so that we can find them by
// key quickly. Implicit (null) keys get added to this set with their index
// instead.
const existingChildren = new Map();
let existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}

Map นั้นคือกลเม็ดทั้งหมด: พอมันมีอยู่ การหา “มี fiber เก่าสำหรับ key ของ element ใหม่ตัวนี้ไหม” คือ lookup แบบ O(1) ไม่ใช่ linear scan — ซึ่งเป็นสิ่งที่ทำให้ pass ทั้งหมดเป็น O(n) แทนที่จะเป็น O(n²) สำหรับ list ที่ reorder และนี่คือเหตุผลเป๊ะ ๆ ว่าทำไม key ต้องเป็น identity ของข้อมูลที่คงที่ ไม่ใช่ array index: mapRemainingChildren ใช้ existingChild.index เป็น fallback key เฉพาะสำหรับ child ที่ ไม่มี key — หมายความว่า “ไม่มี key” กับ “index เป็น key” ให้พฤติกรรม lookup เดียวกันเป๊ะ การใช้ index เป็น key ตรง ๆ ไม่ได้ทำให้คุณได้อะไรฉลาดขึ้นเลย มันแค่ทำสิ่งที่ React ทำอยู่แล้วเป็น default เมื่อไม่มี key เลยซ้ำอีกที ซึ่งเป็นเหตุผลเป๊ะ ๆ ที่มันเปราะบางเมื่อมีการ reorder

พอ fiber ตัวหนึ่งถูก match แล้ว (จาก key map หรือ fast walk) placeChild ตัดสินว่ามันต้องย้ายตำแหน่งจริงใน DOM ไหม:

// packages/react-reconciler/src/ReactChildFiber.js — placeChild (trimmed)
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This existing fiber needs to MOVE forward in the DOM.
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// This item's relative order didn't change — leave it alone.
return oldIndex;
}
} else {
// No alternate at all: this is a brand new insertion.
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}

สังเกตว่า fiber ถูก reuse ทั้งสองกรณี (newFiber.alternate ยังชี้ไปที่ตัวเก่า) — Placement ตรงนี้แปลว่า “ย้าย DOM node ที่มีอยู่แล้วตัวนี้” ไม่เคยแปลว่า “ทำลายแล้วสร้างใหม่” อะไรก็ตามจาก child เก่าที่ไม่เคยถูก match กับ element ใหม่เลย จะผ่าน deleteChild/deleteRemainingChildren แทน ซึ่ง mark Deletion และเตรียม fiber ให้ถูกรื้อ (state, effect, DOM node) ใน commit phase การแยกนี้ — reuse-แล้วอาจย้าย เทียบกับ ถูกลบจริง — สังเกตได้ตรง ๆ: DOM node จริงของ fiber ที่ match ได้รอดจากการ reorder

src/__tests__/reorder-reuses-dom-node.test.tsx
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('reordering a keyed list reuses the same DOM node — it moves, it is not destroyed', () => {
function List({ items }: { items: string[] }) {
return (
<ul>
{items.map((id) => <li key={id} data-id={id}>{id}</li>)}
</ul>
);
}
const { container, rerender } = render(<List items={['a', 'b', 'c']} />);
const aBefore = container.querySelector('[data-id="a"]');
rerender(<List items={['c', 'a', 'b']} />);
const aAfter = container.querySelector('[data-id="a"]');
expect(aAfter).toBe(aBefore); // same node instance, only its position in the DOM changed
});
✓ reordering a keyed list reuses the same DOM node — it moves, it is not destroyed
Test Files 1 passed (1)
Tests 1 passed (1)

<Fragment key={...}> (หรือเทียบเท่า <React.Fragment key={...}> — syntax สั้น <>...</> รับ key ไม่ได้) ผ่าน Fragment branch ของ reconcileSingleElement ซึ่งเช็ก child.tag === Fragment แทน elementType แต่นอกนั้นตามกฎ “key ต้องตรง ไม่งั้นนี่คือของคนละอัน” เป๊ะเดียวกัน keyed Fragment ที่ห่อ child หลายตัว มีพฤติกรรม ในแง่ reconciliation เหมือนกับ keyed element ที่ห่อตัวเดียวเป๊ะ — state ของมัน (และ state ของ child ข้างใน) ตาม key ข้ามการ reorder:

src/__tests__/keyed-fragment.test.tsx
import { Fragment, useState } from 'react';
import { render, cleanup, act } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Toggle({ label }: { label: string }) {
const [on, setOn] = useState(false);
return (
<button data-label={label} data-on={on} onClick={() => setOn(true)}>
{label}
</button>
);
}
function Group({ ids }: { ids: string[] }) {
return (
<div>
{ids.map((id) => (
<Fragment key={id}>
<Toggle label={id} />
</Fragment>
))}
</div>
);
}
test('a keyed Fragment carries its child\'s state across a reorder, exactly like a keyed element would', () => {
const { container, rerender, getByText } = render(<Group ids={['a', 'b']} />);
act(() => { getByText('a').click(); });
rerender(<Group ids={['b', 'a']} />);
const aButton = container.querySelector('[data-label="a"]');
expect(aButton?.getAttribute('data-on')).toBe('true'); // state followed "a" across the reorder
});
✓ a keyed Fragment carries its child's state across a reorder, exactly like a keyed element would
Test Files 1 passed (1)
Tests 1 passed (1)
flowchart TD
  start["reconcileChildFibers(returnFiber, currentFirstChild, newChild)"] --> isArray{"newChild is an array?"}
  isArray -->|"no — single element/Fragment"| single["reconcileSingleElement
check key AND type/tag"]
  single -->|"key+type match"| reuse1["reuse fiber, update props"]
  single -->|"mismatch"| rebuild1["delete old, create new fiber"]
  isArray -->|"yes — a list"| fast["Fast walk: old[i] vs new[i]
by position, while keys line up"]
  fast -->|"key mismatch found"| map["mapRemainingChildren
build key -> old fiber Map (O(1) lookup)"]
  map --> place["placeChild per new element:
found in map -> reuse + maybe Placement
not found -> new fiber + Placement"]
  map --> del["old fibers never matched -> deleteChild
(Deletion flag)"]
reconcileChildFibers: single element เทียบกับ keyed array

reconciliation ถามคำถามเดียวกันเสมอ — “สิ่งที่ตำแหน่งนี้คือของเดิม หรือของใหม่” — แต่ตอบด้วยเครื่องมือต่างกันตามรูปทรง สำหรับ child ตัวเดียว มันคือการเช็ก key-แล้ว-type ตรง ๆ กับ fiber ตัวเดียวที่อยู่ตรงนั้น สำหรับ list มันคือการเช็กเดียวกัน แค่ batch ผ่าน hash map เพื่อให้ยังคง linear แทนที่จะเป็น quadratic ไม่ว่าทางไหน “เดิม” แปลว่า reuse (และอาจย้าย) fiber “ใหม่” แปลว่า delete ตัวเก่าแล้วสร้างใหม่ตั้งแต่ศูนย์ — ไม่มีตัวเลือกที่สาม ไม่มีการ reuse บางส่วนของ fiber ที่ “คล้ายกัน”

ทางเลือกประโยชน์ต้นทุน
แยก code path single-element กับ arrayแต่ละ path เรียบง่ายสำหรับรูปทรงของตัวเอง ไม่มี overhead ของ generic tree-diff สำหรับกรณี “child เดียว” ที่พบบ่อยต้องเข้าใจสอง algorithm ไม่ใช่หนึ่ง
Key-map (mapRemainingChildren) สำหรับ list ที่ reorderO(n) โดยรวมแทนที่จะเป็น O(n²) — สำคัญขึ้นเมื่อ list ใหญ่ขึ้นทำงานก็ต่อเมื่อ fast positional walk เจอ mismatch เท่านั้น; list ที่ reorder ทั้งหมดต้องเสียค่าสร้าง map ทั้งอัน
ต้องมีทั้ง key+type ตรงกันถึงจะ reuse fiber ได้ป้องกัน component ที่ต่างกันจริง ๆ ไม่ให้สืบทอด state ของกันและกันโดยบังเอิญแค่เพราะ key ชนกันcomponent ที่เปลี่ยน identity กลางคัน (หายาก แต่มีจริง — เช่นสลับ implementation ภายใต้ key เดิม) เสีย state เสมอ ต่อให้ตั้งใจสลับก็ตาม
  • “key ที่ตรงกันพอแล้วสำหรับรักษา state ของ component” พิสูจน์แล้วว่าไม่จริงข้างบน: key เดียวกันแต่ type ต่างกัน (function object ที่สร้างใหม่) ยัง remount เต็มรูปแบบอยู่ดี — reconcileSingleElement เช็กทั้งสองอย่าง
  • “reorder list ทำลายแล้วสร้างไอเทมที่ย้ายใหม่” พิสูจน์แล้วว่าไม่จริงข้างบน: DOM node ของไอเทมที่ reorder-แต่ยังอยู่ เป็น object reference เดียวกันเป๊ะหลัง reorder — placeChild mark มันเป็น Placement (ย้าย) ไม่ใช่ Deletion + insertion ใหม่
  • “ใช้ array index เป็น key อย่างน้อยก็ดีกว่าไม่มี key” มันคือสิ่งเดียวกัน — mapRemainingChildren fallback ไปที่ existingChild.index เฉพาะเมื่อไม่มี key ดังนั้น key={index} ที่ใส่ตรง ๆ สร้างพฤติกรรมเดียวกันเป๊ะกับการไม่มี key เลย
  • “<>...</> กับ <Fragment key={...}> ใช้แทนกันได้” ไม่ได้สำหรับ list: syntax สั้นรับ key ไม่ได้ ดังนั้นมันเข้าร่วม keyed reconciliation แบบที่ <Fragment key={...}> ตรง ๆ ทำได้ไม่ได้ — พิสูจน์แล้วข้างบน
  • callback ของ .map() ประกาศ component function ที่ซ้อนอยู่ข้างใน (function Row() {...} หรือ const Row = () => {...}) แล้ว render มันด้วย key ไหม? key ไม่ช่วยอะไร — ตัว function เองถูกสร้างใหม่ทุก render ซึ่งเป็น type ใหม่ พิสูจน์แล้วข้างบนด้วยการ remount เต็มรูปแบบ
  • list ใช้ <>...</> (Fragment syntax สั้น) ข้างใน .map() ห่อ child หลายตัว โดยไม่มี key บนมันเลยไหม? นั่นคือปัญหา “ไม่มี key” แบบเงียบ ๆ สำหรับ group ของ child ทั้งกลุ่มพร้อมกัน — ต้องใช้ <Fragment key={...}> แทน
  • โค้ดสมมติว่า key={index} ปลอดภัยเพราะ “ลำดับ list ไม่เคยเปลี่ยน” ไหม? นั่นจริงแค่จนกว่ามันจะเปลี่ยน — และ failure mode (state ไปลงแถวผิด) พิสูจน์แล้วข้างบนและใน Rendering & Reconciliation ไม่ใช่สมมติฐาน
  • mental model ของ reviewer สมมติว่า reconciliation ทำ “similarity diff” ทั่วไประหว่าง child เก่ากับใหม่ (หา “match ที่ใกล้เคียงที่สุด”) ไหม? มันไม่ทำแบบนั้น — มันคือการ match key-แล้ว-type แบบเป๊ะผ่าน hash map ไม่ใช่ fuzzy matching; key/type pair ที่เปลี่ยนชื่อหรือโครงสร้างใหม่ถูกปฏิบัติเป็นของใหม่ทั้งหมดเสมอ ไม่เคยถูกปรับให้เข้ากัน

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

function CommentList({ comments }) {
return (
<div>
{comments.map((c) => (
<>
<p key={c.id}>{c.text}</p>
<hr />
</>
))}
</div>
);
}
เฉลย

key={c.id} อยู่บน <p> ข้างใน fragment ไม่ใช่บนตัว fragment เอง — และนี่ใช้ syntax สั้น <>...</> ซึ่งรับ key ไม่ได้เลย React เห็น group ของ child สองตัว (<p> + <hr>) ที่ไม่มี key ซ้ำกันทุก comment; ภายใน mapRemainingChildren fallback ไปเทียบเท่ากับใช้ index เป็น key ของ fragment แต่ละอัน (พิสูจน์แล้วข้างบนว่าพฤติกรรมเหมือนไม่มี key เป๊ะ) ดังนั้นการแทรกหรือลบ comment กลาง list ทำให้ fragment ทุกอันหลังจุดนั้น mismatch เหมือนกับ index key ตรง ๆ ทุกประการ ทางแก้คือ <Fragment key={c.id}>...</Fragment> (form ยาว import จาก 'react') ซึ่งทำให้ group ด้านนอกเข้าร่วม keyed reconciliation ได้เอง

ใน reconcileSingleElement React เช็กสองอย่างอะไรบ้างก่อนจะ reuse fiber เก่าให้กับ element ใหม่?
mapRemainingChildren สร้างอะไรขึ้นมา และมันมีไว้ทำไม?
flag Placement แปลว่าอะไรเมื่อมันถูก apply กับ fiber ที่มี alternate อยู่แล้ว (คือมันเคยมีอยู่ก่อนหน้านี้)?
ตาม source ของ mapRemainingChildren React ใช้อะไรเป็น key ภายในสำหรับ child ที่ไม่มี key prop ระบุไว้เลย?
การใช้ key ของโค้ดนี้ผิดตรงไหน?
{items.map((id) => (
  <>
    <p key={id}>{id}</p>
    <hr />
  </>
))}