Why React & the Model
Imperative เทียบกับ declarative
หัวข้อที่มีชื่อว่า “Imperative เทียบกับ declarative”ลองนึกถึง 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” หายไปทั้งกลุ่ม
component คือฟังก์ชันของ input ของตัวเอง
หัวข้อที่มีชื่อว่า “component คือฟังก์ชันของ input ของตัวเอง”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 คือฟังก์ชัน”:
- props เป็น read-only component ต้องไม่แก้ props ของตัวเอง — เป็นของ parent ข้อมูลไหล ลง
- input เดียวกัน output เดียวกัน เมื่อ props และ state เท่าเดิม component ควร render สิ่งเดิม นั่นคือข้อกำหนดเรื่อง purity (มีบทเรียนเต็ม ๆ ทีหลัง) และเป็นสิ่งที่ทำให้ React รัน component ซ้ำได้อย่างอิสระ
One-way data flow
หัวข้อที่มีชื่อว่า “One-way data flow”ข้อมูลไหลทางเดียวเสมอ: ลง จาก 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
// 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 เองไม่เคยขยับขึ้นเลย
พิสูจน์: UI = f(state) ไม่ใช่แค่คำเล่า
หัวข้อที่มีชื่อว่า “พิสูจน์: UI = f(state) ไม่ใช่แค่คำเล่า”สมการนี้ไม่ใช่แค่คำขวัญ — มันแปลว่า component function ตัวเดียวกันเป๊ะ ๆ เมื่อถูกเรียกด้วย state ต่างกัน จะได้ UI ต่างกันแบบ deterministic โดยไม่มีโค้ด DOM มือเขียนที่ไหนเลย:
function Counter({ count }: { count: number }) { return <p>{count}</p>;}
export default Counter;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)Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างที่เหลือใน 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 flow | state มีเจ้าของเดียว คิดตามง่ายว่าใครเปลี่ยนอะไร | 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 ที่เขียนมือ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ 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 ไม่มีวันขัดกัน