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

Why React & the Model

ลองนึกถึง counter เวอร์ชัน imperative บอก browser ว่าจะ update อย่างไร ทีละขั้น:

// Imperative: you manage the DOM and keep it in sync by hand.
let count = 0;
const label = document.querySelector("#count");
document.querySelector("#inc").addEventListener("click", () => {
count += 1;
label.textContent = count; // you must remember to do this, everywhere
});

จุดที่เกิด bug คือส่วน “ต้องจำว่าต้อง update” — ทุกที่ที่เปลี่ยน count ก็ต้อง update ทุกจุดของ UI ที่ขึ้นกับค่านั้นด้วย พลาดจุดเดียวหน้าจอก็โกหกทันที

เวอร์ชัน declarative อธิบายว่า UI เป็นอะไร สำหรับ state หนึ่ง ๆ และไม่แตะ DOM เลย:

// Declarative: describe the UI for the current state; React syncs the DOM.
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

ไม่มีขั้น “update label” คุณแค่เปลี่ยน count และเพราะ UI ถูก อธิบาย ในรูปของ count React จึง re-render และหน้าจอถูกต้องโดยอัตโนมัติ bug ประเภท “ลืม update X” หายไปทั้งกลุ่ม

React component คือฟังก์ชันที่รับ props (input จาก parent) และใช้ state (ความจำของตัวเอง) แล้ว return คำอธิบายของ UI

// props flow in; state lives inside; the return describes the UI.
function Greeting({ name }) { // name is a prop
const [count, setCount] = useState(0); // count is state
return (
<div>
<p>Hello, {name}</p>
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
</div>
);
}
flowchart LR
  props["props (from parent)"] --> comp["Component function"]
  state["state (own memory)"] --> comp
  comp --> ui["returns: UI description
(elements)"]
  ui --> react["React renders it"]
component แปลง input เป็นคำอธิบาย UI

สองกฎที่ตามมาจาก “component คือฟังก์ชัน”:

  • props เป็น read-only component ต้องไม่แก้ props ของตัวเอง — เป็นของ parent ข้อมูลไหล ลง
  • input เดียวกัน output เดียวกัน เมื่อ props และ state เท่าเดิม component ควร render สิ่งเดิม นั่นคือข้อกำหนดเรื่อง purity (มีบทเรียนเต็ม ๆ ทีหลัง) และเป็นสิ่งที่ทำให้ React รัน component ซ้ำได้อย่างอิสระ

ข้อมูลไหลทางเดียวเสมอ: ลง จาก parent ไปหา child ในรูป props ไม่มี two-way binding ที่ child จะดัน value กลับเข้าตัวแปรของ parent แบบเงียบ ๆ child ที่อยากเปลี่ยนสิ่งที่ parent เป็นเจ้าของ ต้อง “ขอ” ผ่านการเรียก function ที่ parent ส่งลงมาให้เป็น prop

flowchart TB
  parent["Parent: owns the state"] -->|"props (data down)"| child["Child: reads props, cannot write them"]
  child -->|"calls onX(...) (event up)"| handler["Parent's handler runs"]
  handler -->|setState| parent
ข้อมูลไหลลงเป็น props การเปลี่ยนแปลงไหลขึ้นเป็นการเรียก event
// Parent owns `count`; Child only ever RECEIVES it and a callback.
function Parent() {
const [count, setCount] = useState(0);
return <Child count={count} onIncrement={() => setCount((c) => c + 1)} />;
}
// Child never touches `count` directly — it can only call the function
// the parent handed it. The parent decides what "increment" means.
function Child({ count, onIncrement }) {
return <button onClick={onIncrement}>{count}</button>;
}

นี่คือเหตุผลที่การส่ง callback prop ลงมา (onIncrement) ไม่ได้ละเมิด one-way flow — ข้อมูล (count) ยังไหลลงทางเดียวอยู่ดี child แค่เรียก function ที่ parent เขียนไว้ ไม่ต่างจากการคลิก <button> แล้วไปเรียก handler ที่คุณเขียน state เองไม่เคยขยับขึ้นเลย

สมการนี้ไม่ใช่แค่คำขวัญ — มันแปลว่า component function ตัวเดียวกันเป๊ะ ๆ เมื่อถูกเรียกด้วย state ต่างกัน จะได้ UI ต่างกันแบบ deterministic โดยไม่มีโค้ด DOM มือเขียนที่ไหนเลย:

src/components/Counter.tsx
function Counter({ count }: { count: number }) {
return <p>{count}</p>;
}
export default Counter;
src/__tests__/ui-is-f-of-state.test.tsx
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import Counter from '../components/Counter';
afterEach(cleanup);
test('the same function, called with different state, produces different UI automatically', () => {
const { rerender, container } = render(<Counter count={0} />);
expect(container.textContent).toBe('0');
// No DOM write anywhere in this test or in Counter — just a new input.
rerender(<Counter count={5} />);
expect(container.textContent).toBe('5');
});
✓ the same function, called with different state, produces different UI automatically
Test Files 1 passed (1)
Tests 1 passed (1)

ทุกอย่างที่เหลือใน React เป็นผลพวงของ UI = f(state):

  • hooks มีไว้เพื่อให้ function component มี state และ lifecycle โดยไม่ ทำลาย model “เป็นแค่ฟังก์ชัน”
  • reconciliation มีไว้เพื่อแปลง “นี่คือคำอธิบาย UI ใหม่” ให้เป็น “นี่คือการแก้ DOM น้อยที่สุด” (การแบ่ง render/commit แบบเต็ม ๆ กับกฎการ diff อยู่ในบทถัดไป Rendering & Reconciliation)
  • concurrent features (transitions, Suspense) มีอยู่ได้เพราะ f(state) ที่ pure สามารถถูกคำนวณ หยุด และ restart ได้อย่างปลอดภัย

คุณไม่ได้กำลังเรียน API กองใหญ่ คุณกำลังเรียนสมการเดียวและผลของตัวเอง

ทางเลือกประโยชน์ต้นทุน
Declarative (UI = f(state))bug ประเภท “ลืม update X” เป็นไปไม่ได้โดยโครงสร้างเสียการควบคุม DOM แบบ fine-grained โดยตรง
โค้ด DOM แบบ imperativeควบคุมได้เต็มที่ ไม่ต้องเรียน frameworkต้องหาและ sync ทุกจุดที่ขึ้นกับข้อมูลด้วยมือ — scale ไม่ดี
One-way data flowstate มีเจ้าของเดียว คิดตามง่ายว่าใครเปลี่ยนอะไรchild ที่ลึกมากและอยากกระทบ ancestor ที่ไกล ต้องส่ง callback prop ผ่านทุกชั้น (หรือใช้ Context บทหลัง)
  • “declarative แปลว่า React re-render DOM ทั้งหมดทุกครั้ง” มันแค่ รัน component function ใหม่ ซึ่งเป็นการคำนวณล้วน ๆ DOM ถูกแตะแค่ส่วนที่เปลี่ยนจริงตอน commit phase (บทถัดไป)
  • “one-way data flow แปลว่า child ไม่มีทางกระทบ parent ได้เลย” กระทบได้ แต่ผ่านการเรียก function ที่ parent ส่งมาเป็น prop เท่านั้น — ข้อมูล ยังไหลลงทางเดียวอยู่ดี child ไม่เคยเขียนเข้า state ของ parent ตรง ๆ
  • “ส่ง callback prop ลงมาทำลาย one-way flow เพราะ parent ถูก ‘แจ้งกลับ’” ไม่ทำลาย — child แค่เรียก function ไม่ได้ mutate ตัวแปรที่ถูกส่งมา ไม่ต่างจาก <button> ที่เรียก onClick ของคุณ
  • “โค้ด declarative ช้ากว่าเพราะแค่อธิบายแทนที่จะทำเลย” การอธิบาย (render phase) ถูกมาก React ยังคำนวณและใช้แค่การแก้ DOM จริงน้อยที่สุด เหมือนโค้ด imperative ที่เขียนมือ

เมื่อ component ที่ generate มา “sync” UI ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • component เขียนเข้า DOM ตรง ๆ ไหม (.textContent =, .style.x =, document.title =) แทนที่จะ return ค่านั้นจาก render? การเขียนตรง ๆ จะถูก overwrite แบบเงียบ ๆ ทันทีที่ React re-render subtree นั้นด้วยเหตุผลอื่น
  • child component พยายามเปลี่ยน value ที่รับมาเป็น prop ตรง ๆ แทนที่จะเรียก callback prop ที่ parent ให้มาไหม?
  • มี two-way binding แบบ implicit ไหม — child mutate object/array ที่รับมาโดย reference แล้วหวังว่า parent จะ “เห็น” การเปลี่ยนเอง? ข้อมูลต้องไหลกลับขึ้นผ่านการเรียก function ชัดเจน ไม่ใช่ผ่านการ mutate
  • state ก้อนเดียวกันถูกคำนวณสองที่ไหม (คำนวณใน parent แล้วมาคำนวณซ้ำใน child) แทนที่จะมีเจ้าของเดียวแล้วไหลลงเป็น prop?
  • ถ้าโค้ดอ้างว่า “UI update อัตโนมัติ” — มันขับเคลื่อนด้วย state change จริงไหม หรือพึ่งการเขียน DOM มือที่จะทำงานได้แค่จนกว่าจะมี re-render อื่นมาทับ?

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

function Counter() {
const [count, setCount] = useState(0);
const labelRef = useRef(null);
function handleFakeUpdate() {
// Bypasses React entirely — writes straight to the DOM.
labelRef.current.textContent = '999';
}
return (
<div>
<span ref={labelRef}>{count}</span>
<button onClick={handleFakeUpdate}>fake update</button>
<button onClick={() => setCount((c) => c + 1)}>real increment</button>
</div>
);
}
เฉลย

ปุ่ม “fake update” เขียน '999' เข้า DOM ตรง ๆ ข้าม state ไป — และดูเหมือนได้ผล แต่ทันทีที่มี state change จริง ๆ ทำให้ React re-render component นี้ (คลิก “real increment”) React จะอธิบาย <span> ใหม่จาก count (ที่ไม่เคยถูกแตะ ยังเป็น 0) แล้ว patch DOM กลับไปตาม model ของตัวเอง พิสูจน์แล้วในโพรบ: หลังคลิก “fake update” แล้วคลิก “real increment” span จะโชว์ '1' — ไม่ใช่ '1000' และไม่ใช่ '999' — การเขียนมือหายไปแบบไร้ร่องรอย ทางแก้คือส่ง value ผ่าน useState/setCount เพื่อให้ model ของ React กับ DOM ไม่มีวันขัดกัน

ปัญหาหลักของการ update DOM แบบ imperative ที่ React แก้ให้คืออะไร?
input ของ React component คืออะไร?
ข้อใดจริงเกี่ยวกับ props?
child component อยากเปลี่ยน value ที่ parent เป็นเจ้าของ มันทำอะไรได้ถูกต้อง?
render ด้วย count={0} แล้ว re-render ด้วย count={5} โดยไม่มีโค้ดอื่นเลย container จะโชว์อะไร?
function Counter({ count }: { count: number }) {
  return <p>{count}</p>;
}
ปุ่มหนึ่งเรียก function นี้แทน setCount หลังคลิกมันแล้วคลิกปุ่ม *อื่น* ที่เรียก setCount(c => c + 1) label จะโชว์อะไร?
function handleFakeUpdate() {
  labelRef.current.textContent = '999';
}