Skip to content

บทที่ 14 — React Internals (Fiber + Reconciliation + Concurrent Rendering)

← บทที่ 13: TypeScript Deep | สารบัญ | บทที่ 15: Accessibility + i18n + SEO →

🔴 ระดับ: สูง — อ่านทีหลังได้

Prerequisite: ควรใช้ React ทำ project มาแล้วอย่างน้อย 1 ตัว + เคยเจอปัญหา performance หรือ bug ในงานจริง

บทนี้คือ "ใต้ฝากระโปรง" ของ React — ไม่จำเป็นต่อการเขียนแอปทั่วไป อ่านเพื่อเข้าใจว่าทำไม React ทำแบบนี้ + debug performance ยากๆ ได้

📖 คำศัพท์รวมของบท (อ่านก่อน):

  • internals = กลไกภายใน
  • Fiber = สถาปัตยกรรมภายในของ React ตั้งแต่ v16 (2017) — เก็บ component เป็น "node" ที่ resume ได้ (React 19 ยังคงใช้ Fiber เป็น foundation พร้อม React Compiler ที่ optimize บน top ของมัน)
  • reconciliation = กระบวนการเทียบ Virtual DOM เก่า ↔ ใหม่ แล้วอัปเดต DOM จริงเฉพาะส่วนที่ต่าง
  • concurrent rendering = render แบบขนาน — แบ่งงานเป็นชิ้นเล็ก ทำสลับได้ ไม่ค้างจอ
  • Lane = "ช่องทาง" priority ใน scheduler (bitmask 32-bit) — ดูรายละเอียดใน Part 6.6 ของบทนี้
  • time-slicing = การแบ่งเวลา render เป็นช่วงสั้น ๆ (~5ms) ไม่ block UI
  • batching = การรวมหลาย setState ให้ re-render ครั้งเดียว — ประหยัด render
  • SSG = Static Site Generation — สร้าง HTML ล่วงหน้าตอน build ไม่ใช่ตอน request
  • TTI = Time To Interactive — เวลาที่หน้าเว็บเริ่มตอบสนอง user ได้
  • bitmask = trick การเก็บหลายค่า boolean ในเลข integer ตัวเดียว (เช่น 0b011 = bit 1 + bit 2 ทั้งคู่)
  • work loop = loop หลักที่ React เดินสำรวจ Fiber tree
  • commit phase vs render phase = 2 ขั้นของ React (calculate vs apply ลง DOM)
  • hooks linked list = hook ถูกเก็บเป็น linked list (ไม่ใช่ array) ใน Fiber
  • Flight protocol = format ที่ RSC ใช้ส่งข้อมูลจาก server → client

13 บทที่ผ่านมา (บทที่ 00-13) เราใช้ React แบบ "ใช้ได้" — แต่ไม่รู้ว่ามันทำงานยังไง

บทนี้ลงไปดู ใต้พรม:

  • React DOM render ยังไง? Virtual DOM คือสิ่งใด?
  • Fiber = architecture ใหม่ (React 16+) คืออะไร?
  • Reconciliation = diff อย่างไร?
  • Concurrent Rendering = useTransition, useDeferredValue, Suspense ทำงานยังไง?
  • React Compiler (React 19+) ทำอะไรกับ memoization?
  • Server Components / Streaming SSR

เมื่อจบ คุณจะ debug performance ของ React ได้ + เข้าใจว่า hook rule "ห้ามใส่ใน if" มาจากไหน

ใช้เวลา 6-8 ชั่วโมงสำหรับการอ่านครั้งแรก + lab (junior อาจมากกว่า — แนะนำแบ่งเป็นหลาย session ไม่ต้องอ่านรวดเดียว)


Part 1: Virtual DOM ทบทวน

1.1 ปัญหา DOM ปกติ

javascript
document.getElementById("count").innerText = "0";
document.getElementById("count").innerText = "1";

ทุกครั้งที่แตะ DOM:

  • Browser layout (reflow)
  • Repaint
  • ช้ามากถ้าทำเยอะ

1.2 Virtual DOM

React เก็บ "tree ของ object" ใน memory แทน DOM จริง:

typescript
// JSX
<div className="card">
  <h1>{title}</h1>
  <p>{body}</p>
</div>

// แปลเป็น object:
{
  type: "div",
  props: {
    className: "card",
    children: [
      { type: "h1", props: { children: title } },
      { type: "p", props: { children: body } }
    ]
  }
}

ตอน state เปลี่ยน:

  1. React สร้าง tree ใหม่
  2. Diff (เปรียบเทียบความต่าง) tree เก่ากับใหม่ → หา minimum changes
  3. Apply เฉพาะส่วนที่เปลี่ยน DOM จริง

DOM ปกติทำน้อยลงมาก

1.3 ⚠️ Virtual DOM ไม่ใช่ "เร็วกว่า DOM"

ความเข้าใจผิด: "React เร็วเพราะ Virtual DOM"

จริง:

  • Virtual DOM เพิ่ม overhead (สร้าง tree + diff)
  • แต่ทำให้ developer experience ดีขึ้น (declarative)
  • การ batch + smart diff ช่วยตอน update เยอะ

→ Solid.js, Svelte (framework JavaScript อื่น ๆ ที่ไม่ใช้ Virtual DOM) ไม่ใช้ Virtual DOM เลย Solid.js ใช้แนวทาง compile-time (แปลงโค้ดตอน build ไม่ใช่ตอน run) ส่วน Svelte ใช้ signal-based (ใช้ reactive signal แทน Virtual DOM) ทั้งสองตัวมักทำผลลัพธ์ดีกว่า React ใน micro-benchmark (การวัดผลแบบจุดเล็ก ๆ — เทียบความเร็วของ operation เฉพาะ เช่น render 1,000 row) หลายตัว (ตัวเลข exact เปลี่ยนตาม benchmark + version — เช็ค krausest/js-framework-benchmark ก่อนอ้างเปรียบเทียบ, flagged 2026-06)


Part 2: Fiber — Architecture ของ React 16+

2.1 ปัญหาก่อน Fiber (React 15-)

ก่อน React 16 การ reconcile (เทียบและอัปเดต DOM — อธิบายละเอียดใน Part 3 ด้านล่าง) เป็น recursive ที่ "เริ่มแล้วต้องทำจนจบ" pause ไม่ได้ — พอ component tree ใหญ่ การ render จะ block main thread นาน ทำให้ UI ค้าง user พิมพ์/คลิกแล้วไม่ตอบ Fiber เกิดมาเพื่อแก้ปัญหานี้:

text
Old reconciliation: recursive — ทำตลอดจนจบ ไม่ pause ได้

→ ถ้า tree ใหญ่ — render block main thread → user เห็น lag

2.2 Fiber = "stack ที่ resumable"

Fiber คือการเปลี่ยน reconciliation จาก recursion (ที่หยุดกลางคันไม่ได้) เป็น "linked list ของ node ที่ resume ได้" — แต่ละ component กลายเป็น Fiber node ที่เก็บ child/sibling/return (parent) + state + flags ทำให้ React เดินทีละ node แล้วหยุดพักได้:

แต่ละ component สร้าง Fiber node (โค้ดด้านล่างแสดงโครงสร้าง — ไม่ต้องจำทุก field แค่สังเกตว่ามี child, sibling, return สำหรับโครงสร้าง tree และ memoizedState สำหรับ hook):

typescript
type Fiber = {
  type: string | Function;       // "div", App
  key: string | null;
  stateNode: HTMLElement | Component;
  child: Fiber | null;
  sibling: Fiber | null;
  return: Fiber | null;          // parent
  alternate: Fiber | null;       // คู่ของ current vs work-in-progress
  memoizedState: any;            // state ของ hook
  memoizedProps: any;
  pendingProps: any;
  flags: number;                 // bitmask: needs update, deletion, ฯลฯ
  // ... อีกเยอะ
};

Tree ของ Fiber:

text
App (Fiber)
  └── child: Layout
                └── child: Header
                              └── sibling: Main
                                            └── child: Card

2.3 ทำไม Fiber resume ได้

แทน recursion → React iterate ผ่าน fiber tree ทีละ node เป็นช่วง ๆ — เช็คว่ามี input ของ user มาไหมทุก ~5ms (time-slicing) ไม่ต้องดู source ตอนนี้ แค่รู้ว่า React แบ่งงานเป็นช่วง ~5ms ก็พอ (ถ้าสนใจดูโค้ดจริง อยู่ที่ packages/scheduler/src/ ใน React GitHub — ตำแหน่งไฟล์อาจเปลี่ยนตาม version, last reviewed: 2026-06) ถ้ามี → pause → handle → resume

โค้ดด้านล่างเป็น pseudocode (โค้ดสมมติ — แสดงแนวคิด ไม่ใช่โค้ดจริงของ React) — workInProgress คือ Fiber node ที่กำลังทำงานอยู่, shouldYield() คือการเช็คว่าถึงเวลาหยุดพักแล้วหรือยัง:

text
Render loop (pseudocode):
while (workInProgress != null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
}
if (shouldYield()) {
    // pause, schedule continuation
}

time-slicing (แบ่งเวลา) = render เป็นช่วง 5ms ๆ ไม่ block 200ms ติด

2.4 Two Trees — current + workInProgress

(current tree กับ workInProgress tree ชี้หากันผ่าน alternate pointer — React ทำงานบน workInProgress แล้ว swap เป็น current เมื่อพร้อม)

  • ทำ render บน workInProgress
  • เสร็จ → swap ทั้ง tree
  • กลายเป็น current ใหม่

atomic switch (สลับทั้ง tree พร้อมกันในครั้งเดียว — ไม่มีสถานะครึ่งเก่าครึ่งใหม่) — user ไม่เคยเห็น UI ครึ่ง ๆ กลาง ๆ

2.5 Phases

text
1. Render phase (ดูว่าอะไรเปลี่ยน)
   - Build workInProgress tree
   - Resumable ได้ — interrupt ได้
   - ห้ามมี side effect

2. Commit phase (apply DOM)
   - Synchronous — ห้าม pause
   - Mutate DOM
   - Run useLayoutEffect (useLayoutEffect — hook พิเศษที่รันก่อน paint อธิบายเต็มใน Part 4.5 — sync รันก่อน browser paint)
   - useEffect รัน async หลัง paint เสร็จ (ไม่ใช่ส่วนของ commit phase โดยตรง)

Rule: ใน render function (component body) ห้ามมี side effect — เพราะอาจถูก rerun หลายครั้ง ตอน concurrent mode ก่อน commit จริง

concurrent mode คือโหมดทำงานแบบขนาน — React อาจ rerun component หลายครั้งโดยยังไม่ apply ลง DOM จริง (commit) เพื่อ schedule update ให้เหมาะสม

รายละเอียดเพิ่มเติมจะอธิบายใน Part 6


Part 3: Reconciliation Algorithm

3.1 Heuristic (กฎลัดที่ใช้ประมาณผล — ไม่ perfect แต่เร็ว)

⚠️ สถานะ scheduler yield threshold ~5ms ในหัวข้อก่อนหน้า: จริงๆ เป็นค่า adaptive (ปรับตาม frame rate ที่วัดได้จริง) ไม่ใช่ค่าคงที่ตายตัว — 5ms คือตัวเลขที่มักถูกอ้างอิงเพื่อความเข้าใจง่าย ไม่ใช่ค่า guarantee ตายตัว

React ใช้ heuristic 2 ข้อ:

  1. Element type ต่าง → unmount + remount ทั้ง subtree

    text
    <div><Counter/></div>
        ↓ ถ้าเปลี่ยนเป็น
    <span><Counter/></span>
        
    → <Counter> unmount → state หาย!
  2. List ต้องมี key เพื่อ identify ตัวเดิม

    typescript
    {items.map(item => <Item key={item.id} {...item} />)}

3.2 ทำไม key สำคัญ

❌ ใช้ index เป็น key:

typescript
{items.map((item, i) => <Input key={i} defaultValue={item.text} />)}

ถ้า items shift (เพิ่ม item ใหม่ที่ตำแหน่งแรก — ทำให้ index ของทุกตัวเลื่อนขึ้น):

text
Before: [A, B, C]   index: 0, 1, 2
After:  [Z, A, B, C] index: 0, 1, 2, 3

React คิดว่า:
  key=0 → "A" → ตอนนี้เป็น "Z" → reuse Input ของ A, update value เป็น Z
  key=1 → "B" → ตอนนี้เป็น "A" → reuse B, update เป็น A
  ...

DOM state ไม่ตรง user input

✅ ใช้ unique id:

typescript
{items.map(item => <Input key={item.id} defaultValue={item.text} />)}

→ React match key=id-z ใหม่ → mount Input ใหม่; key เก่าอยู่ Index ที่ถูก

3.3 Diffing children

text
Old: <A/> <B/> <C/>
New: <A/> <C/>          (B หายไป)

→ B → deletion
→ C → reuse (key เดียวกัน)

ถ้าไม่มี key → React match by index → reuse A, อ่าน B → update เป็น "C", ลบที่ index 2

ผลลัพธ์เหมือนกัน — แต่ถ้า B กับ C มี internal state → state พัง


Part 4: Hook Internals

4.1 Hook = linked list ใน Fiber

React รู้ได้ยังไงว่า useState ตัวไหนเป็นตัวไหน?

คำตอบ: hook ทั้งหมดของ component เก็บเป็น linked list ใน Fiber เรียงตามลำดับการเรียก React จับคู่ค่าด้วย ลำดับ ไม่ใช่ชื่อ นี่คือเหตุผลที่ห้ามเรียก hook ใน if (ดูหัวข้อถัดไป):

typescript
function MyComponent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");
  useEffect(() => { ... });
}

ใน Fiber:

text
memoizedState (head)
  → Hook 1 (useState count, value=0, queue=[]) 
      → next: Hook 2 (useState name, value="", queue=[])
          → next: Hook 3 (useEffect, deps, cleanup)

แต่ละ Hook = node ใน linked list ของ component

4.2 ทำไมห้าม Hook ใน if

typescript
function Bad() {
  const [a, setA] = useState(0);          // index 0
  if (condition) {
    const [b, setB] = useState("");        // index 1 (บางครั้งเท่านั้น)
  }
  const [c, setC] = useState(false);       // index 2 (หรือ 1?)
}

ตอน render 1: [a, b, c] ตอน render 2 (condition = false): [a, c] — React อ่าน index 1 ได้ค่าของ c แทน b!

→ React detect ด้วย linked list order — ต้องเรียกตามลำดับเดียวกันทุกครั้ง

4.3 useState

มาดูว่า useState ทำงานเบื้องหลังอย่างไร (pseudo-code = โค้ดตัวอย่างที่ทำให้เข้าใจ ไม่ใช่โค้ดจริง):

  • render แรก: สร้าง hook node ใหม่ เก็บค่าเริ่มต้น
  • render ถัดมา: apply setState ที่ค้างอยู่ใน queue (คิว — รายการ setState ที่รอดำเนินการ)
  • setState ทำแค่ push ค่าเข้า queue แล้ว schedule update — นี่คือเหตุผลที่ setState ไม่อัปเดตทันที

workInProgressHook คือ module-level pointer ที่ React reset ให้ชี้ไปที่ fiber.memoizedState (head ของ linked list) ก่อนเริ่ม render แต่ละครั้ง แล้วขยับไปทีละ node ตามลำดับ hook ที่ถูกเรียก:

javascript
// simplified pseudo-code — แสดง idea ไม่ใช่ source จริง
// 📖 จริง ๆ Hook ใน Fiber เก็บเป็น linked list (next pointer) ไม่ใช่ array index
// fiber.memoizedState = head ของ linked list, traverse ด้วย hook.next
//
// workInProgressHook = pointer ที่ React reset เป็น fiber.memoizedState ก่อนทุก render
// แล้วขยับ (.next) ทีละ node ตามลำดับ hook ที่ถูกเรียก
function useState(initial) {
  const fiber = getCurrentFiber();

  if (isFirstRender) {
    // First render — สร้าง hook node ใหม่ + ต่อเข้า linked list
    const hook = {
      value: typeof initial === 'function' ? initial() : initial,
      queue: [],
      next: null,                    // pointer ไปยัง hook ถัดไป
    };
    if (fiber.memoizedState == null) {
      fiber.memoizedState = hook;    // hook ตัวแรก = head ของ linked list
    } else {
      lastHook.next = hook;          // ต่อท้าย linked list (lastHook = hook ก่อนหน้า)
    }
    lastHook = hook;                 // อัปเดต lastHook ให้ชี้ไปที่ hook ที่เพิ่งสร้าง
    workInProgressHook = hook;
  } else {
    // Subsequent render — เดิน linked list ต่อจาก workInProgressHook
    workInProgressHook = workInProgressHook.next;  // ขยับไป hook ตัวถัดไป
    // apply setState calls ที่ค้างใน queue
    workInProgressHook.value = applyQueue(workInProgressHook);
  }

  const hook = workInProgressHook;
  const setState = (newValue) => {
    hook.queue.push(newValue);       // push ค่าใหม่เข้า queue (ไม่ update ทันที)
    scheduleUpdate(fiber);           // บอก React ให้ schedule re-render
  };

  return [hook.value, setState];
}

📖 Linked list ไม่ใช่ array — Hook ใน React เป็น linked list (hook.next) มี fiber.memoizedState เป็น head แล้ว traverse ทีละ node คือเหตุที่ "rules of hooks" บังคับให้เรียกในลำดับเดิม — เพราะ pointer ขยับตามลำดับ ถ้าเรียก hook ใน if แล้ว condition เปลี่ยน → traversal pointer ชี้ผิด node = state พัง

(simplified — และยังมี field อื่นอีกมากที่ไม่ได้แสดงในตัวอย่างนี้)

4.4 useEffect

useEffect ก็เก็บใน Fiber เช่นกัน — มันเทียบ deps (dependencies — รายการ value ที่ useEffect ติดตาม) กับ render ก่อน ถ้าเปลี่ยนจึงตั้ง flag ให้รัน effect หลัง commit (และเรียก cleanup (ฟังก์ชันล้างค่าที่ effect ทำไว้ก่อนรอบถัดไป) ของรอบก่อนก่อน) เข้าใจตรงนี้อธิบายว่าทำไม cleanup ถึงรันก่อน effect รอบใหม่ และทำไม deps สำคัญ:

javascript
// (simplified — เทียบ mental model "linked list" จาก §4.3 เดิม)
function useEffect(fn, deps) {
  const fiber = getCurrentFiber();
  const hook = workInProgressHook;       // pointer ตัวเดียวกับใน §4.3
  workInProgressHook = hook.next;        // ขยับไป hook ถัดไป

  const prevDeps = hook.deps;
  if (depsChanged(prevDeps, deps)) {
    fiber.flags |= NEED_EFFECT;
    hook.effect = fn;
    hook.deps = deps;
  }
}

// after commit phase:
function flushPassiveEffects(fiber) {
  // walk fiber tree; แต่ละ fiber ที่มี flag NEED_EFFECT
  // จะ walk hook linked list (hook.next) แล้วรัน cleanup ของรอบก่อน + effect รอบใหม่
  if (hook.cleanup) hook.cleanup();
  hook.cleanup = hook.effect();
}

⚠️ pseudo-code ข้างบนเรียบเรียงให้ตรงกับ mental model "linked list" ของ §4.3 — source จริงใน react-reconciler ซับซ้อนกว่ามาก (มี alternate fiber, effect list แยก ฯลฯ) ถ้าจะอ่าน source จริงให้เริ่มที่ packages/react-reconciler/src/ReactFiberHooks.js

4.5 useLayoutEffect vs useEffect

  • useLayoutEffect — รัน sync หลัง commit DOM แต่ก่อน browser paint → block paint
  • useEffect — รัน async หลัง paint

→ ใช้ useLayoutEffect ตอนต้อง measure DOM แล้ว update เพื่อกัน flicker; ปกติใช้ useEffect

⚠️ useLayoutEffect ไม่ทำงานใน SSR — ต้องใช้ใน Client Component ("use client") เท่านั้น ถ้าใช้ใน Server Component จะได้ warning: "useLayoutEffect does nothing on the server" สำหรับ library code ที่ต้องทำงานทั้ง server + client ให้ใช้ pattern useIsomorphicLayoutEffect (สร้าง hook ที่ใช้ useLayoutEffect บน browser และ useEffect บน server)


Part 5: Batching

Batching = การรวมหลาย setState ให้ re-render ครั้งเดียว — ประหยัด render และทำให้ UI ไม่กระตุก

5.1 React 18 — Automatic Batching

batching คือการรวมหลาย setState ใน event เดียวให้ re-render ครั้งเดียว — React 18 ขยายให้ทำใน async (Promise, setTimeout) ด้วย (ก่อนหน้านี้ batch เฉพาะใน event handler) ผลคือโค้ดเดิมที่เคย render 2 ครั้งใน .then() ตอนนี้ render ครั้งเดียว เร็วขึ้นโดยไม่ต้องแก้อะไร:

ก่อน React 18:

javascript
fetchData().then(() => {
  setCount(c => c + 1);     // re-render
  setLoading(false);         // re-render อีก!
});
// → 2 renders

React 18+:

javascript
fetchData().then(() => {
  setCount(c => c + 1);     // ไม่ render
  setLoading(false);         // ไม่ render
});
// → 1 render (auto-batched)

ทำงานกับทุก event — promise, setTimeout, native event

5.2 flushSync — บังคับ sync

บางครั้ง batching ไม่ใช่สิ่งที่ต้องการ — เมื่อต้องการให้ DOM update ทันทีก่อนทำขั้นต่อไป (เช่น measure DOM หรือ scroll หลัง update) ใช้ flushSync บังคับ React commit ทันที แต่ใช้เท่าที่จำเป็นเพราะมันยกเลิกประโยชน์ของ batching:

javascript
import { flushSync } from "react-dom";

flushSync(() => {
  setCount(c => c + 1);
});
// DOM update แล้ว!
setOther(...);

ใช้ตอนต้อง measure DOM ทันทีหลัง update


Part 6: Concurrent Rendering

6.1 useTransition

typescript
const [isPending, startTransition] = useTransition();

function handleSearch(query: string) {
  setSearchInput(query);                     // urgent — UI update ทันที
  startTransition(() => {
    setSearchResults(filterHeavy(query));    // low priority — interruptible (ยกเลิกกลางคันได้ถ้ามีงานเร่งด่วนมา)
  });
}

ถ้า user พิมพ์ต่อ → React abort การ render ของ results เก่า → start ใหม่

→ UI ไม่ค้าง

isPending = boolean บอกว่า transition กำลังทำงาน

6.2 useDeferredValue

typescript
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);

return (
  <>
    <input value={query} onChange={e => setQuery(e.target.value)} />
    <HeavyList query={deferredQuery} />
  </>
);

deferredQuery ตามหลัง query — React render HeavyList แบบ low priority

ต่าง useTransition: ใช้ใน leaf component (component ปลายสุดใน tree — รับ prop มาจาก parent แต่ไม่ control source state)

6.3 Suspense

ในมุม internals, Suspense ทำงานด้วยกลไกที่น่าสนใจ — เมื่อ component ยังไม่พร้อม (data กำลังโหลด) มัน "throw" Promise ออกมา "throw" ในที่นี้คือการส่ง Promise ออกจาก component เพื่อบอก React ว่า "ยังไม่พร้อม รอก่อน" — เป็นกลไกพิเศษของ React ไม่ใช่ error

จากนั้น Suspense boundary (ตำแหน่งใน component tree ที่มี <Suspense> ครอบอยู่) จับ Promise นั้นไว้ แล้ว render fallback จนกว่า Promise จะ resolve

นี่คือเหตุผลที่ Suspense ทำงานกับทั้ง lazy loading และ data fetching ได้ด้วยกลไกเดียวกัน:

typescript
<Suspense fallback={<Spinner />}>
  <UserProfile id={1} />
</Suspense>

ภายใน UserProfile:

typescript
// ❌ อย่าเขียนแบบนี้ — สร้าง Promise ใหม่ทุก render → Suspense ลูปไม่จบ
function UserProfileBad({ id }) {
  const user = use(fetchUser(id));   // ทุก render = promise ใหม่
  return <div>{user.name}</div>;
}

// ✅ แนวที่ถูก — รับ promise มาจาก parent หรือ cache
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);     // promise reference เดิม → resume ได้
  return <div>{user.name}</div>;
}

// parent (อาจเป็น Server Component) สร้าง promise ครั้งเดียวแล้วส่งลงมา
// <Suspense fallback={<Spinner/>}><UserProfile userPromise={fetchUser(id)} /></Suspense>

⚠️ กฎทอง: อย่าสร้าง Promise ตรงๆ ใน body ของ Client Component ที่ใช้ use() — ให้ใช้ cache (เช่น cache() ของ React) หรือรับ promise มาจาก parent/Server Component (React docs ของ use เตือนเรื่องนี้)

React catch promise → render fallback → wait → resolve → re-render

6.4 Nested Suspense

typescript
<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<ListSkeleton />}>
    <UserList />
  </Suspense>
  <Suspense fallback={<DetailSkeleton />}>
    <UserDetail />
  </Suspense>
</Suspense>

→ Header แสดงก่อน, List + Detail แสดงเมื่อแต่ละอันพร้อม


Part 6.5: Scheduler + Lane Model

🔴🔴 โซนขั้นสูงสุด — ข้ามได้แน่นอน ไม่กระทบ Part 7 (React 19) เป็นต้นไปของบทนี้ ส่วนนี้ลงลึกถึงระดับ bitmask + lane (ช่องลำดับความสำคัญของงาน render) — แม้แต่ dev มืออาชีพส่วนใหญ่ก็ไม่ต้องรู้ระดับนี้ อ่านเอาสนุก/เพื่อความเข้าใจ ไม่ต้องจำ

💡 bitmask คืออะไร — trick การเก็บหลายค่า boolean ในเลข integer ตัวเดียว:

bit 1 = SyncLane, bit 2 = TransitionLane, bit 3 = IdleLane

lane = 0b001 (= 1) → มี SyncLane pending
lane = 0b011 (= 3) → มี SyncLane + TransitionLane pending
lane = 0b101 (= 5) → มี SyncLane + IdleLane pending

ใช้ bitwise operator (| = ใส่, & = ตรวจ, ~ = กลับบิต/NOT — เปลี่ยน 0 เป็น 1 และ 1 เป็น 0) ทำงานกับ bit แต่ละตัว — เร็วมาก (1 CPU instruction) เหมาะกับงาน hot path อย่าง scheduler

หมายเหตุ: ผลจริงของ ~ ใน JavaScript ซับซ้อนกว่านี้ (two's complement — วิธีเก็บเลขลบของคอมพิวเตอร์ ทำให้ ~0b001 ได้ -2 ไม่ใช่ตัวเลข bit ตรงไปตรงมา) — ตัวอย่างข้างบนคือ mental model แบบง่ายพอสำหรับเข้าใจ bitmask ไม่ใช่ผลลัพธ์ตรงจากการรันจริงใน console

6.5 ทำไม useTransition กับ useDeferredValue ทำงานต่างกัน?

ก่อน React 18 — React มี priority แค่ "sync vs async" → ไม่พอ React 18+ — แนะนำ Lane model (โมเดลแบ่งงาน render เป็น "ช่อง" ตามลำดับความสำคัญ): priority ละเอียดกว่า

6.6 Lane = ช่อง priority

ลองนึกภาพทางด่วน 31 ช่อง (31 lanes — เก็บเป็น bitmask = เลขฐานสอง 32-bit แต่ละบิตคือ 1 lane, สงวนไว้ 1 บิต):

text
SyncLane               (bit 1)  — สูงสุด — handler ของ user event, input
InputContinuousLane    (2 lanes) — drag, scroll continuous input
DefaultLane            (2 lanes) — render ปกติ (state update นอก transition)
TransitionLanes        — มี 16 ช่องย่อย สำหรับ transition งานหลายตัวพร้อมกัน (startTransition / useTransition)
RetryLanes             (4 lanes)  — Suspense retry
SelectiveHydrationLane          — selective hydration
IdleHydrationLane               — hydration ของ component ที่ไม่ urgent
IdleLane                        — งานที่ไม่จำเป็น
OffscreenLane                   — sub-tree ที่ซ่อนอยู่ (<Activity> — canary API (เวอร์ชัน experimental ที่ยังไม่ stable) ดูต่อใน §6.10 ด้านล่าง)

แต่ละ render scheduler ดูว่า "เลน priority สูงสุดที่ค้าง" = ทำเลนนั้นก่อน

📖 ความจริงทางเทคนิค — Lane ใน React 18+ คือ bitmask 32-bit, แต่ละ "Lane" คือ 1 bit (เช่น SyncLane = 0b0...010) ไม่ใช่ "Lane 0/1/2" แบบ array index รวมแล้วใช้ ~31 bits (สงวน 1 bit) จัดกลุ่มตามชนิดงาน ค่าตัวเลขจริงเปลี่ยนได้ใน source — สนใจกลุ่ม/priority order มากกว่าบิตเลขจริง

6.7 Lane assignment ใน practice

tsx
function App() {
    const [query, setQuery] = useState('');           // → DefaultLane
    const [results, setResults] = useState<string[]>([]);
    const [isPending, startTransition] = useTransition();

    function handleChange(e: ChangeEvent<HTMLInputElement>) {
        setQuery(e.target.value);                      // → SyncLane (event handler — จัดการ event ทันที)
        startTransition(() => {
            setResults(heavyFilter(e.target.value));   // → TransitionLane (ลำดับต่ำกว่า — ถูก interrupt ได้)
        });
    }

    return (
        <>
            <input value={query} onChange={handleChange} />
            {isPending && <Spinner />}
            <Results items={results} />
        </>
    );
}

→ user พิมพ์เร็ว ๆ — React process SyncLane updates ก่อน (input ตัวอักษรขึ้น) — TransitionLane ของ results ถูก interrupt + restart ตลอด

6.8 ทำไม TransitionLane interruptible?

Lane ที่มี priority ต่ำกว่า → ถ้ามี lane priority สูงกว่ามาระหว่างทาง → abandon work + start over

text
Time 0ms  user พิมพ์ "a" → SyncLane: setQuery("a") commit
                          TransitionLane: setResults(heavyFilter("a")) start...

Time 30ms (กำลัง render results ยังไม่เสร็จ)
          user พิมพ์ "ab" → SyncLane เข้ามา → React abort transition work
                          → commit setQuery("ab") (sync)
                          → restart TransitionLane: heavyFilter("ab") ใหม่

Time 200ms transition จบ → commit results

→ user รู้สึก "input ลื่น" — เพราะ React ไม่ใช้เวลากับงานเก่าที่ user ไม่สนแล้ว

6.9 useDeferredValue เทียบกับ useTransition

tsx
// useTransition — control source ของ update
const [isPending, startTransition] = useTransition();
startTransition(() => setX(value));   // ⭐ บอก React ว่า "setX นี้เป็น transition lane"

// useDeferredValue — control consumption ของ value
const deferredValue = useDeferredValue(value);   // ⭐ บอก React ว่า "render with stale value ก็ได้"

ตารางเทียบ:

useTransitionuseDeferredValue
ควบคุมsource (setState)consumption (read value)
ใช้เมื่อเป็นเจ้าของ stateรับ value จาก parent/context (ไม่ control source)
Boolean indicatorisPendingใช้ value !== deferredValue
Side effectmark update เป็น transitionmark render ของ subtree เป็น low priority

ตัวอย่าง useDeferredValue ที่ useTransition ทำไม่ได้:

tsx
function SearchResults({ query }: { query: string }) {
    const deferredQuery = useDeferredValue(query);   // ⭐ ไม่ได้เป็นเจ้าของ setQuery
    const isStale = query !== deferredQuery;

    return (
        <div style={{ opacity: isStale ? 0.5 : 1 }}>
            <HeavyList query={deferredQuery} />
        </div>
    );
}

→ Parent control query (setQuery จาก input) — SearchResults แค่รับมา + defer render ของ HeavyList

6.10 Activity API (canary, ปี 2026) — เก็บ state ของ subtree ที่ซ่อน

⚠️ สถานะ API: <Activity> ยัง canary ณ มิ.ย. 2026 — API อาจเปลี่ยน, ชื่อ export อาจเปลี่ยน, หรือยังไม่อยู่ใน React stable release ที่คุณใช้ ก่อนใช้ใน production เช็ค React canary changelog + react.dev เสมอ (last reviewed: 2026-06)

<Activity mode="hidden"> = subtree ถูก unmount visually แต่ keep state + effects pause

tsx
// ⚠️ canary API — ยืนยัน import path จาก React canary changelog ก่อนใช้
import { Activity } from 'react';

function Tabs({ activeTab }: { activeTab: string }) {
    return (
        <>
            <Activity mode={activeTab === 'home' ? 'visible' : 'hidden'}>
                <HomePanel />    {/* state ของ scroll position, form input คงอยู่ */}
            </Activity>
            <Activity mode={activeTab === 'settings' ? 'visible' : 'hidden'}>
                <SettingsPanel />
            </Activity>
        </>
    );
}

ต่าง {activeTab === 'home' && <HomePanel />} ตรงที่ Activity เก็บ state ไว้ — switch tab กลับมาเหมือนเดิม

→ Use case:

  • tab switcher — สลับ tab โดยไม่ลืม scroll position/form ของ tab ก่อน
  • sidebar collapse — พับ sidebar แล้วกางคืน เนื้อหายังเหมือนเดิม
  • modal hide — ซ่อน modal โดยไม่ unmount เปิดใหม่ได้เร็ว (keep state)

Part 7: React 19 — Compiler + Actions

7.1 React Compiler — auto memoization

React 19 มี compiler ที่ auto-insert useMemo + useCallback — ปัจจุบันใช้ Babel plugin (babel-plugin-react-compiler) รวมถึงใน Next.js 15 ด้วย (Babel/SWC คือเครื่องมือแปลง JavaScript ที่ใช้ตอน build — ไม่ต้องรู้รายละเอียดตอนนี้ แค่รู้ว่า React Compiler ทำงานอัตโนมัติผ่านเครื่องมือเหล่านี้) SWC plugin ของ React Compiler ยังอยู่ระหว่างพัฒนา — ตรวจสอบ react.dev/learn/react-compiler สำหรับ status ล่าสุด:

typescript
// คุณเขียน:
function Component({ items }: { items: { price: number }[] }) {
  const total = items.reduce((s, x) => s + x.price, 0);
  return <div>{total}</div>;
}

// Compiler แปลงเป็น (อัตโนมัติ):
function Component({ items }: { items: { price: number }[] }) {
  const total = useMemo(() => items.reduce((s, x) => s + x.price, 0), [items]);
  return <div>{total}</div>;
}

→ ไม่ต้องเขียน useMemo เอง! (แต่ต้องเขียน "rules of React" ตาม — ได้แก่ ไม่เรียก hook ใน if/loop, ไม่ mutate prop/state โดยตรง, component ต้องเป็น pure function; ดูรายละเอียดที่ react.dev/reference/rules)

7.2 Actions (Server + Form)

React 19 เพิ่มแนวคิด "Action" ที่จัดการ async form ให้ครบ — useActionState รวม pending state, error และ result ไว้ใน hook เดียว ผูกกับ <form action={...}> โดยตรง แทนที่จะเขียน useState หลายตัว + try/catch + จัดการ loading เอง ลดโค้ด boilerplate (โค้ดซ้ำซ้อนที่ต้องเขียนเหมือนกันในทุก form) ของฟอร์มลงมาก:

typescript
function MyForm() {
  // [state, formAction, isPending] — state คือค่าที่ action function return มา (ไม่จำเป็นต้องเป็น error เสมอ)
  const [state, submitAction, isPending] = useActionState(async (prev, formData) => {
    try {
      await saveUser({ name: formData.get("name") });
      return null;           // action สำเร็จ — return null
    } catch (e) {
      return (e as Error).message;  // action ล้มเหลว — return error message
    }
  }, null);

  return (
    <form action={submitAction}>
      <input name="name" />
      <button disabled={isPending}>Save</button>
      {state && <p>{state}</p>}
    </form>
  );
}

→ ไม่ต้อง useState + try/catch แยกแล้ว

7.3 use Hook

use เป็น API ใหม่ของ React 19 ที่ "อ่าน" Promise หรือ Context — ต่างจาก hook อื่นตรงที่เรียกใน if/loop ได้ (ไม่ผูกกฎ hook order ที่ต้องคงลำดับเดิม — ดู §4.2) แต่ยังต้องเรียกภายใน React component หรือ custom hook เท่านั้น ใช้กับ Promise คู่ Suspense (unwrap async value) หรือใช้แทน useContext แบบยืดหยุ่นกว่า:

typescript
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

ใช้ใน Suspense — use ห้อย Promise + integrate

ใช้กับ Context ก็ได้:

typescript
const theme = use(ThemeContext);   // เหมือน useContext แต่ใช้ใน if ได้!

Part 8: Server Components (RSC)

8.1 ปัญหาที่ RSC แก้

CSR (Client-Side Rendering — render ที่ browser):

  • JS bundle ใหญ่ — ส่งทั้งหมดให้ browser
  • First load ช้า — wait JS download + execute

SSR (Server-Side Rendering — render ที่ server):

  • Server render HTML → ส่งให้ browser
  • ดี SEO, first paint เร็ว
  • แต่ JS bundle ยังต้องส่ง — hydration (กระบวนการ "เชื่อมต่อ" HTML ที่ server ส่งมากับ React บน client — อธิบายเพิ่มใน 8.5)
    • double work: render ที่ server + ที่ client

RSC:

  • Server component ไม่ส่ง JS เลย — render บน server → ส่ง "RSC payload" (ข้อมูลพิเศษที่ React ใช้บน client เพื่อ render ต่อ — ไม่ใช่ HTML ธรรมดา ไม่มี JS code ของ server component)
  • Client component ส่ง JS ตามปกติ
  • → bundle เล็กลงมาก, server access DB ตรง ๆ ได้

8.2 Server vs Client Component

เส้นแบ่งสำคัญของ RSC — Server Component (default) รันบน server เท่านั้น เข้า DB ตรงได้ แต่ใช้ state/event ไม่ได้ ส่วน Client Component (มาร์ค "use client") รันบน browser มี interactivity ได้ (ตอบสนอง user ได้ เช่น click, input) แต่ไม่มี JS ของ server การเลือกให้ถูกชนิดเป็นกุญแจของ performance ใน Next:

typescript
// app/products/[id]/page.tsx — server component (default ใน Next.js App Router)
async function ProductPage({ id }: { id: string }) {
  const product = await db.product.findById(id);    // DB access ใน component!
  return <ProductDetails product={product} />;
}
typescript
// app/products/[id]/AddToCart.tsx — ต้องอยู่คนละไฟล์กับ Server Component
"use client";                            // ต้องเป็นบรรทัดแรกสุดของไฟล์ ก่อน import ทุกอย่าง
import { useState } from "react";

function AddToCart() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

8.3 Boundary

text
Server Component
  ├── Server Component
  └── Client Component   ← boundary
        ├── Client Component
        └── Client Component

ใน client component ต้องส่ง prop ที่ serializable (แปลงเป็น text/JSON ได้ — เช่น string, number, array, object) เท่านั้น — ไม่ส่ง function JavaScript ธรรมดาจาก server ได้ แต่ Server Actions (ฟังก์ชันที่มาร์ก "use server") ส่งได้ผ่าน React 19 mechanism พิเศษ

8.4 Streaming SSR

typescript
// Next.js App Router
export default async function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<Spinner />}>
        <SlowData />        {/* stream มาทีหลัง */}
      </Suspense>
    </>
  );
}

Server ส่ง HTML แรกพร้อม Header + <Spinner /> ก่อน, แล้ว stream SlowData ตามมา (เป็น chunk)

8.5 Hydration Mismatch — bug ที่ SSR เจอบ่อยสุด

Hydration = process ที่ client เอา HTML จาก server มา "ติด event handler" ให้กลายเป็น interactive React app

Hydration mismatch = HTML ที่ server render ต่างจากที่ client คาดหวัง → console error:

text
Hydration failed because the server rendered HTML didn't match the client.
Text content does not match server-rendered HTML.

(หมายความว่า: HTML ที่ server render กับที่ client คาดว่าจะเห็นต่างกัน — มักเกิดจากการใช้ค่าที่ต่างกันระหว่าง server กับ browser)

สาเหตุที่พบบ่อย:

tsx
// ❌ Date.now() / Math.random() — server กับ client ค่าต่างกัน
function Comp() {
    return <div>{Date.now()}</div>;        // server: 1700000000, client: 1700000100
}

// ❌ window/document ใน render — server ไม่มี
function Comp() {
    return <div>{window.innerWidth}</div>; // ReferenceError: window is not defined (ตอน SSR render บน server)
}

// ❌ Locale-dependent format ที่ server/client ต่าง timezone
function Comp() {
    return <div>{new Date().toLocaleString()}</div>;
}

// ❌ Conditional ตาม browser feature ที่ server ไม่รู้
function Comp() {
    const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
    return <div className={isDark ? 'dark' : 'light'} />;
}

ทางแก้ (เรียงจากดีสุด):

tsx
// ✅ ทาง 1 — ใช้ useEffect (run client only)
function Comp() {
    const [time, setTime] = useState<number | null>(null);
    useEffect(() => setTime(Date.now()), []);
    if (time === null) return <div>Loading...</div>;
    return <div>{time}</div>;
}

// ✅ ทาง 2 — suppressHydrationWarning (เฉพาะ element เดียว ที่รู้ว่า diff ปลอดภัย)
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>

// ✅ ทาง 3 — onRecoverableError ที่ hydrateRoot (catch + report)
hydrateRoot(container, <App />, {
    onRecoverableError: (err) => {
        console.error('Hydration recovered:', err);
        // ส่งเข้า monitoring (Sentry/Datadog)
    },
});

💡 Selective Hydration (เลือก hydrate (ติด event handler ให้ HTML ที่ server ส่งมา เพื่อให้ทำงานได้ใน browser) — React 18+): ถ้า Suspense boundary ยัง pending → hydrate ส่วนที่พร้อมก่อน, ส่วน suspended รอ data ค่อย hydrate ทีหลังเมื่อ data มา ทำให้ TTI (Time To Interactive — เวลาที่หน้าเริ่มกดได้) ดีขึ้นมาก

💡 สำหรับ ID ที่ generate ขึ้นมา (เช่น label htmlFor + input id) ให้ใช้ useId() แทนการสร้าง ID เอง — useId() รับประกันว่า server กับ client ได้ค่าเดียวกัน ไม่เกิด hydration mismatch


Part 9: Profiling + Debug

9.1 React DevTools Profiler

เครื่องมือหลักในการหา performance problem คือ React DevTools Profiler — record การ interact แล้วดู flame graph ว่า component ไหน render นาน, render กี่ครั้ง และ "ทำไมถึง render" (props/state/parent เปลี่ยน) ใช้หาจุดที่ควร optimize แทนการเดา:

  1. Open DevTools → tab "Profiler"
  2. Click record → exercise UI → stop
  3. ดู flame graph (กราฟรูปเปลวไฟ — แสดงว่าแต่ละ component ใช้เวลา render นานแค่ไหน แต่ละบล็อกคือ 1 component):
    • Component ที่ render นาน
    • Render count
    • Why did this render? (props/state/parent changed)

9.2 <Profiler> API

นอกจาก DevTools ยังวัด render time ในโค้ดได้ด้วย <Profiler> component — ครอบส่วนที่อยากวัดแล้วรับ callback ที่บอก phase + duration มีประโยชน์เมื่ออยากเก็บ metric อัตโนมัติ (ส่งไป analytics) หรือ assert ใน test ว่า render ไม่นานเกิน:

typescript
import { Profiler } from "react";

function App() {
  return (
    <Profiler
      id="UserList"
      onRender={(id, phase, actualDuration) => {
        // id, phase, actualDuration, baseDuration, startTime, commitTime ก็มีด้วยถ้าต้องการ
        console.log(`${id} ${phase}: ${actualDuration}ms`);
      }}
    >
      <UserList />
    </Profiler>
  );
}

→ log render time ทุกครั้ง

9.3 Find unnecessary re-render

ปัญหา performance ที่พบบ่อยสุดคือ re-render เกินจำเป็น — มีเครื่องมือช่วยหา: why-did-you-render (log ว่าทำไม component re-render) และ "Highlight updates" ใน DevTools (flash สีตอน component re-render) ทำให้เห็นด้วยตาว่าส่วนไหน render บ่อยผิดปกติ:

  • ลง why-did-you-render (dev only):
    bash
    npm i -D @welldone-software/why-did-you-render
    แล้ว import ใน dev entry file เช่น src/wdyr.ts → จะ log ว่าทำไม component re-render
  • ใช้ React DevTools "Highlight updates" → component flash when re-renders

9.4 React Strict Mode

typescript
import { StrictMode } from 'react';

<StrictMode>
  <App />
</StrictMode>

ใน dev:

  • Render component 2 ครั้ง — เผย impure component
  • Run effect 2 ครั้ง — เผย missing cleanup
  • ไม่ใช้ใน production

Part 10: Common Performance Pitfalls

10.1 Anonymous function ใน prop

กับดักแรก: ส่ง arrow function inline เป็น prop ทำให้สร้าง function ใหม่ทุก render — child ที่ memo ไว้จึง re-render เปล่า ๆ เพราะ prop "เปลี่ยน" ทุกครั้ง แก้ด้วย useCallback (หรือปล่อยให้ React Compiler จัดการ):

typescript
// ❌ recreate function ทุก render → child ที่ memo ไม่ได้ผล
<Child onClick={() => doIt()} />

// ✅ useCallback (hook สำหรับ cache function ไว้ใช้ซ้ำ — สร้างใหม่เฉพาะเมื่อ dependency เปลี่ยน)
//    หรือใช้ React Compiler แทนก็ได้ — compiler จัดการให้อัตโนมัติ
const handleClick = useCallback(() => doIt(), []);
<Child onClick={handleClick} />

10.2 Object literal ใน prop

ปัญหาเดียวกับ function — object/array literal inline ก็สร้างใหม่ทุก render ({} ใหม่ != {} เก่า แม้ค่าเท่ากัน) ทำให้ memoization พัง แก้ด้วย useMemo หรือย้ายค่าคงที่ออกนอก component:

typescript
// ❌ recreate object ทุก render
<Child style={{ color: "red" }} />

// ✅
const style = useMemo(() => ({ color: "red" }), []);

10.3 Big state object

เก็บทุกอย่างไว้ใน state object เดียวทำให้ update ส่วนเล็ก ๆ ก็ทำให้ทั้ง object เปลี่ยน → ทุก consumer re-render — แยก state เป็นชิ้นเล็กตามการใช้งาน ให้แต่ละส่วน update อิสระและกระทบเฉพาะ component ที่ใช้:

typescript
// ❌
const [state, setState] = useState({ users: [], orders: [], settings: {} });
// → update 1 ส่วน → ทั้ง state เปลี่ยน → ทุก consumer re-render

// ✅ แยก state
const [users, setUsers] = useState([]);
const [orders, setOrders] = useState([]);

10.4 Context ใหญ่เกิน

หลักการเดียวกันใช้กับ Context — ยัด user + theme + settings ไว้ context เดียว พอ setTheme ทุก consumer (รวมที่ใช้แค่ user) ก็ re-render แยก context ตามความถี่ของการเปลี่ยนแปลง ลด re-render ที่ไม่จำเป็น:

typescript
// ❌ 1 context มีทั้ง user + theme + settings
// → setTheme → user component ก็ re-render

// ✅ แยก context
<UserContext>
  <ThemeContext>
    <SettingsContext>

10.5 Lifting state สูงเกิน

ยก state ขึ้นสูงเกินจำเป็น (ไป root ทั้งที่ใช้แค่ subtree เล็ก ๆ) ทำให้ทุก render ของ root กระทบทั้ง tree — เก็บ state ให้ใกล้จุดที่ใช้มากที่สุด (colocation) เพื่อจำกัดขอบเขตการ re-render:

ถ้า state ใช้แค่ subtree → keep state ใน subtree ไม่ใช่ root


Part 11: Lab

Lab 1: ดู Fiber tree

ลองเชื่อมทฤษฎี Fiber กับของจริง — เปิด React DevTools tab "Components" แล้วสำรวจ component tree ที่ตรงกับ Fiber tree ที่เรียนมา, expand ดู hook state ของแต่ละ component เห็นว่า memoizedState ที่พูดถึงเก็บอะไรจริง ๆ:

ลง React DevTools extension ใน Chrome/Firefox ที่ react.dev/learn/react-developer-tools → เปิดหน้าที่มี React app → DevTools tab "Components" → ดู structure ของ component tree, expand เห็น hook state

Lab 2: useTransition demo

ทดลอง concurrent rendering ของจริง — ทำ search ที่ filter list ใหญ่ ๆ โดยแยก urgent update (input) ออกจาก low-priority update (results) ด้วย useTransition แล้วสังเกตว่าพิมพ์ลื่นไม่ค้างแม้ list หนัก เห็นผลของ time-slicing ที่เรียนมา:

typescript
import { useState, useTransition, ChangeEvent } from 'react';
// ChangeEvent<HTMLInputElement> คือ TypeScript type สำหรับ event ของ input
// ถ้ายังไม่รู้ TypeScript แค่เปลี่ยนเป็น (e: any) ไปก่อน

function Spinner() { return <div>Loading...</div>; }  // placeholder — สร้างเอง

function Search() {
  const [query, setQuery] = useState("");
  const [list, setList] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    startTransition(() => {
      // simulate heavy compute — สร้าง 20000 items ทั้งหมดเพื่อจำลอง "งานหนัก"
      // ในตัวอย่างนี้ แม้จะ render แค่ 100 ตัวแรก (ดู .slice ด้านล่าง) ก็ยังต้องสร้างครบ 20000 ก่อน
      const result = [];
      for (let i = 0; i < 20000; i++) {
        result.push(`${e.target.value} ${i}`);
      }
      setList(result);
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      {/* key={i} ใช้ได้ที่นี่ — list นี้เป็น read-only ไม่มี internal state ใน li ไม่มีการ reorder */}
      <ul>{list.slice(0, 100).map((s, i) => <li key={i}>{s}</li>)}</ul>
    </>
  );
}

→ ลองพิมพ์เร็ว — input responsive

⚠️ หมายเหตุ: startTransition ไม่ yield ภายใน sync codefor loop ข้างบนยังคง block main thread ตอนรันจริง แต่ประโยชน์คือ React สามารถ abort การ commit render ของ result เก่าได้ถ้า priority เปลี่ยน (user พิมพ์ใหม่) ทำให้ไม่เสียเวลา render เลขเก่า ถ้าอยากให้ตัว loop เองไม่ block ต้องแบ่ง chunk (แบ่งงานเป็นส่วนเล็ก ๆ) เอง เช่น requestIdleCallback (Web API ที่ให้ browser เรียก callback ตอน idle — ไม่ยุ่งกับ UI) หรือ scheduler.yield() ใน Chrome 129+

Lab 3: Suspense + use

หมายเหตุ: ตัวอย่างนี้เป็น React ธรรมดา (เช่นรันบน Vite) — ถ้าจะ copy ไปวางในไฟล์ Next.js App Router ต้องเติม "use client" ไว้บรรทัดแรกของไฟล์ เพราะ pattern นี้ (useMemo + interactivity ฝั่ง client) ต้องเป็น Client Component

typescript
import { use, useMemo, Suspense } from 'react';

// type สำหรับ user data
type User = { name: string };

// จำลองการโหลดข้อมูล — setTimeout ทำให้รอ 1 วินาทีก่อนได้ข้อมูล
async function fetchUser(id: number): Promise<User> {
  return new Promise(resolve =>
    setTimeout(() => resolve({ name: `User ${id}` }), 1000)
  );
}

function Spinner() { return <div>Loading...</div>; }  // placeholder

function App() {
  // useMemo ป้องกัน fetchUser ถูกเรียกซ้ำทุก render
  const userPromise = useMemo(() => fetchUser(1), []);
  return (
    <Suspense fallback={<Spinner />}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);   // Suspense จะ render Spinner จนกว่า promise จะ resolve
  return <div>{user.name}</div>;
}

Part 12: Checkpoint

📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย — เป็นคำถามทบทวนให้ตอบเองจากเนื้อหาในบท ถ้าตอบไม่ได้ ให้ย้อนกลับไปอ่าน section ที่เกี่ยวข้อง

  1. Virtual DOM "เร็วกว่า DOM" จริงไหม?
  2. Fiber ต่างจาก React 15 ยังไง?
  3. ทำไม Hook ใส่ใน if ไม่ได้?
  4. key สำคัญยังไง? ใช้ index เป็น key มีปัญหาเมื่อไหร่?
  5. useLayoutEffect ต่าง useEffect ยังไง?
  6. Automatic Batching ใน React 18 ทำอะไร?
  7. useTransition ใช้ตอนไหน?
  8. Suspense กับ use ทำงานยังไงด้วยกัน?
  9. Server Component ต่างจาก Client Component?
  10. React Compiler ทำอะไร?

Part 13: สรุปบทนี้

  • Virtual DOM = tree object → diff → patch DOM
  • Fiber = linked-list architecture ที่ pause/resume ได้
  • Reconciliation = diff tree + match by type + key
  • Hook = linked list ใน fiber — ต้องเรียกตามลำดับเดียวกัน
  • Concurrent: useTransition, useDeferredValue, Suspense
  • React 19: Compiler (auto memo), Actions, use hook
  • RSC = render บน server, ไม่ส่ง JS — ใช้ใน Next.js App Router (Next.js App Router จะเรียนละเอียดในบทถัดๆ ไป)
  • Profiling: DevTools + Strict Mode
  • Scheduler (lane model) = React จัด priority render เป็น "lane" (sync, default, transition, idle) — useTransition mark งานเป็น transition lane → interrupt ได้
  • Activity API (canary, ปี 2026) = <Activity mode="hidden"> keep state ของ component ที่ "ซ่อนอยู่" โดยไม่ unmount → tab switch ที่ไม่ลืม scroll position

บทถัดไป (สุดท้ายของหมวด React) — Accessibility + i18n + SEO


← บทที่ 13 | สารบัญ | บทที่ 15: Accessibility + i18n + SEO →


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12