State Snapshot & Purity
state เป็น snapshot ไม่ใช่ตัวแปรที่มีชีวิต
หัวข้อที่มีชื่อว่า “state เป็น snapshot ไม่ใช่ตัวแปรที่มีชีวิต”นี่คือเรื่องที่คนเข้าใจผิดมากที่สุดใน React ตอนคุณอ่านตัวแปร state คุณกำลังอ่านค่าของตัวเอง สำหรับ render นี้ — snapshot ที่ถูกแช่แข็ง ณ ตอนที่ component ถูกเรียก ไม่เปลี่ยนกลาง render แม้หลังจากคุณเรียก setter แล้ว
function Counter() { const [count, setCount] = useState(0);
function handleClick() { setCount(count + 1); setCount(count + 1); setCount(count + 1); // count is 0 for this whole render. All three read the SAME snapshot (0), // so this sets state to 0 + 1 = 1, three times. Result: 1, not 3. }
return <button onClick={handleClick}>{count}</button>;}ถ้าจะ update จากค่า ล่าสุด ที่ pending อยู่ ให้ส่ง updater function — React รัน updater เหล่านั้นตามลำดับกับค่าที่กำลังเปลี่ยน:
setCount((c) => c + 1);setCount((c) => c + 1);setCount((c) => c + 1);// Each receives the previous pending value: 0→1→2→3. Result: 3.Mental model: re-render คือการเรียกฟังก์ชันของคุณใหม่พร้อม snapshot ใหม่ ตัวแปรของ render เก่าหายไปแล้ว render ใหม่เห็นค่าใหม่ เรื่องนี้จริงแม้กระทั่งกับค่าที่ถูก capture ไว้ใน closure ที่มีชีวิตอยู่นานกว่า render ที่มันถูกสร้าง — ตัวอย่างคลาสสิกคือการ schedule อะไรบางอย่างแบบ asynchronous จากในตัว event handler:
import { useState } from 'react';import { render, cleanup, fireEvent } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('a closure created during a render keeps reading THAT render\'s snapshot, even after state changes later', async () => { const seen: number[] = []; function Delayed() { const [count, setCount] = useState(0);
function logLater() { // Closes over `count` from THIS render — not a live reference. setTimeout(() => seen.push(count), 0); }
return ( <div> <button onClick={() => setCount((c) => c + 1)}>inc</button> <button onClick={logLater}>log</button> </div> ); }
const { getAllByRole } = render(<Delayed />); const [inc, log] = getAllByRole('button');
fireEvent.click(log); // schedules a log of count = 0 (this render's snapshot) fireEvent.click(inc); // count becomes 1 — but the ALREADY-SCHEDULED closure still has 0
await new Promise((resolve) => setTimeout(resolve, 10)); expect(seen).toEqual([0]);});✓ a closure created during a render keeps reading THAT render's snapshot, even after state changes later
Test Files 1 passed (1) Tests 1 passed (1)Object.is bail-out: ตั้งค่าเดิมทำให้ข้าม render
หัวข้อที่มีชื่อว่า “Object.is bail-out: ตั้งค่าเดิมทำให้ข้าม render”React ตัดสินว่าการเรียก setState ต้อง render ใหม่ไหมโดยเทียบค่าใหม่กับค่าปัจจุบันด้วย Object.is (การเทียบ same-value แบบเดียวกับ === แต่แก้ edge case NaN/-0) ถ้าเหมือนกัน React จะ bail-out และข้าม re-render ของ component นั้นไปเลย — การเรียกนั้นไม่ใช่งานเปล่าที่กลายเป็น render ที่ไม่ทำอะไร แต่เป็นการข้าม render จริง ๆ:
import { useState } from 'react';import { render, cleanup, fireEvent } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('setCount(sameValue) does not cause an extra render', () => { let renders = 0; function Counter() { const [count, setCount] = useState(5); renders++; return <button onClick={() => setCount(5)}>{count}</button>; }
const { getByRole } = render(<Counter />); const baseline = renders; fireEvent.click(getByRole('button')); // setCount(5) — identical to the current 5 expect(renders).toBe(baseline);});
test('setCount(differentValue) DOES cause a render, for contrast', () => { let renders = 0; function Counter() { const [count, setCount] = useState(5); renders++; return <button onClick={() => setCount(6)}>{count}</button>; }
const { getByRole } = render(<Counter />); const baseline = renders; fireEvent.click(getByRole('button')); expect(renders).toBe(baseline + 1);});✓ setCount(sameValue) does not cause an extra render✓ setCount(differentValue) DOES cause a render, for contrast
Test Files 1 passed (1) Tests 2 passed (2)นี่คือการเทียบแบบเดียวกันเป๊ะกับที่ React ใช้ตัดสินว่าการ mutate state “ได้ผล” ไหม — ซึ่งเป็นเหตุผลที่บั๊กในหัวข้อถัดไปเขียนพลาดได้ง่ายมาก
มอง state เป็น immutable
หัวข้อที่มีชื่อว่า “มอง state เป็น immutable”เพราะ state เป็น snapshot ที่ React เทียบด้วย identity คุณจึง ต้องไม่ mutate state การ mutate object หรือ array ในที่เดิมยังคง reference เดิม — ดังนั้น check Object.is ข้างบนเห็นว่าไม่เปลี่ยนแล้ว bail-out ทั้งที่ข้อมูลข้างในเปลี่ยนไปจริง ๆ:
import { useState } from 'react';import { render, cleanup, fireEvent } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
test('mutating state then calling the setter with the SAME reference leaves the UI stale', () => { let renders = 0; function TodoList() { const [todos, setTodos] = useState<string[]>(['a']); renders++; return ( <div> <button onClick={() => { todos.push('b'); // mutates the existing array in place setTodos(todos); // same reference as before — React bails out }} > mutate </button> <span>{todos.length}</span> </div> ); }
const { getByRole, container } = render(<TodoList />); const baseline = renders; fireEvent.click(getByRole('button'));
expect(renders).toBe(baseline); // no re-render happened expect(container.querySelector('span')!.textContent).toBe('1'); // UI still says 1, even though the array is secretly length 2});
test('spreading into a NEW array (different reference) re-renders correctly, for contrast', () => { function TodoList() { const [todos, setTodos] = useState<string[]>(['a']); return ( <div> <button onClick={() => setTodos([...todos, 'b'])}>add</button> <span>{todos.length}</span> </div> ); }
const { getByRole, container } = render(<TodoList />); fireEvent.click(getByRole('button')); expect(container.querySelector('span')!.textContent).toBe('2');});✓ mutating state then calling the setter with the SAME reference leaves the UI stale✓ spreading into a NEW array (different reference) re-renders correctly, for contrast
Test Files 1 passed (1) Tests 2 passed (2)เวอร์ชัน mutate ไม่ได้แค่ “ดูผิดทีหลัง” — มันเบี่ยงเบนไปเงียบ ๆ ตั้งแต่วินาทีนั้นเลย: todos ยาว 2 จริง ๆ ในหน่วยความจำ แต่หน้าจอ (และ render ในอนาคตใด ๆ จนกว่าจะมีอย่างอื่นมาบังคับให้ render) ยังโชว์ 1 ค้างอยู่ ให้สร้าง reference ใหม่เสมอ:
// ❌ Mutation — same array reference, React may not re-render, and it's a bug.todos.push(newTodo);setTodos(todos);
// ✅ New reference — React sees a change and re-renders.setTodos([...todos, newTodo]);render ต้อง pure
หัวข้อที่มีชื่อว่า “render ต้อง pure”การ render ของ component — ทุกอย่างที่ทำระหว่างคำนวณ JSX ที่จะ return — ต้อง pure: เมื่อ props และ state เท่าเดิม ต้อง return output เดิมและไม่มี side effect ระหว่าง render คุณต้องไม่:
- mutate props, state หรือตัวแปร/object ใด ๆ ที่มีอยู่ก่อน;
- เขียน DOM, ยิง network request หรือเริ่ม timer;
- อ่านหรือเขียนอะไรก็ตามนอกฟังก์ชันที่เปลี่ยนได้
flowchart LR inputs["props + state (snapshot)"] --> render["render (pure): compute JSX only"] render --> out["same inputs → same JSX"] render -. NOT here .-> se["side effects: DOM, network, timers"] se --> eff["→ event handlers & effects"]
Side effect อยู่ใน event handler (สิ่งที่เกิดตอน interaction) และ effect (useEffect สำหรับ sync กับระบบภายนอก) — ไม่ใช่ใน body ของ render ข้อกำหนดเรื่อง purity นี้ไม่ใช่แค่ style guideline: React Compiler auto-memoize component และ hook ของคุณโดย สมมติ ว่า render เป็น pure ถ้า render แอบ mutate อะไรหรืออ่านค่าที่เปลี่ยนได้ระหว่างการเรียกแต่ละครั้ง การ memoize ของ compiler อาจส่งผลลัพธ์ที่ cache ไว้แล้วเก่าหรือผิดออกมา เพราะมันจะไม่รันโค้ดใหม่เลยถ้ามันตัดสินใจแล้วว่า input ไม่เปลี่ยน
StrictMode double-render โดยตั้งใจ
หัวข้อที่มีชื่อว่า “StrictMode double-render โดยตั้งใจ”ใน development <StrictMode> ของ React เรียกฟังก์ชัน component ของคุณสองครั้ง โดยตั้งใจ (และรัน effect setup→cleanup→setup) นี่ไม่ใช่ bug — เป็นตัวตรวจจับ ถ้า render ของคุณ pure การเรียกสองครั้งจะให้ output เหมือนกันและไม่มีอะไรพัง ถ้าคุณเผลอ mutate อะไรหรือพึ่ง side effect ระหว่าง render การเรียกซ้ำจะเผยให้เห็น bug ทันทีใน development แทนที่จะเป็น glitch ลึกลับใน production (ด้าน mount/unmount กับการ reorder ของ reconciliation — รวมถึง double-invoke ระดับ effect ของ StrictMode — อยู่ในบทก่อนหน้าแบบละเอียด Rendering & Reconciliation)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก render ได้ props กับ state เป็นสำเนาที่แช่แข็งของตัวเอง — มองมันเป็นภาพถ่าย ไม่ใช่วิดีโอสด การเรียก setState ไม่ได้แก้ภาพที่คุณถืออยู่ มันขอให้ React ถ่ายภาพใหม่ Object.is คือ check ที่ React ใช้ตัดสินว่า “ภาพใหม่ต่างจากภาพก่อนจริงไหม หรือข้ามการล้างฟิล์มไปได้เลย” — และ check นี้ดูแค่ reference identity ของ object/array ไม่เคยดูเนื้อหาข้างในเลย purity คือสิ่งที่ทำให้ทั้งหมดนี้เชื่อถือได้: ถ้าชี้กล้องไปที่ฉากเดิมแล้วได้ภาพต่างกันเพราะ side effect ที่ซ่อนอยู่ ทั้ง bail-out, memoization ของ compiler และ check ของ StrictMode ก็ไม่มีความหมายอะไรเลย
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
Updater function setX(x => ...) สำหรับค่าที่คำนวณจาก state ก่อนหน้า | ถูกต้องเสมอแม้ setter ตัวเดิมถูกเรียกมากกว่าหนึ่งครั้งต่อ event | ต้อง pure — ห้ามมี side effect log/mutate ข้างใน |
| สร้าง object/array ใหม่ทุกครั้งที่ update state | ทั้ง Object.is bail-out ของ React และ memoization ของ Compiler ทำงานถูกต้อง | allocation มากกว่าการ mutate ในที่เดิม (ปกติไม่สำคัญ บางครั้งคุ้มที่จะใช้ library อย่าง Immer สำหรับ state ที่ nested ลึก) |
พึ่ง Object.is bail-out (เช่น setCount(sameValue)) | ข้าม render ที่สูญเปล่า “ได้ฟรี” ไม่ต้องเขียนโค้ดเพิ่ม | ใช้ได้แค่กับค่าที่ reference/primitive เท่ากันจริง — object ที่ mutate แล้วไม่มีวันผ่าน |
render pure, side effect อยู่ใน useEffect/handler | เข้ากันได้กับ Compiler, StrictMode และ concurrent rendering | ต้องมีวินัย — เผลอใส่ mutation หรือ Date.now() ลงใน render ได้ง่าย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “เรียก
setCount(count + 1)สามครั้งจะบวก 3” มันบวกแค่ 1 — ทั้งสาม call เห็นcountsnapshot เดียวกันจาก render นี้ พิสูจน์ด้านบนด้วยคู่ fail/pass - “mutate object แล้วเรียก setter ด้วย reference เดิมก็ยังทำงานได้ปกติ แค่ผิด best practice” มันอาจข้าม re-render แบบเงียบ ๆ ได้เพราะ bail-out check ของ React คือการเทียบ reference (
Object.is) — พิสูจน์แล้วข้างบน: array ยาว 2 ในหน่วยความจำ แต่ UI ยังโชว์ 1 - “Object.is bail-out เกี่ยวกับ performance อย่างเดียว” มันอธิบาย บั๊ก ด้วย — กลไกเดียวกันเป๊ะที่ข้าม
setCount(5)ซ้ำแบบ “ไม่มีพิษภัย” ก็คือตัวที่กลืน update ของ array/object ที่ mutate แล้วอย่างเงียบ ๆ - “purity เป็นแค่ style guideline ไม่มีอะไรบังคับจริง” memoization ของ React Compiler พึ่งพามันจริงจัง — render ที่ไม่ pure ทำให้ Compiler ส่งผลลัพธ์ cache ที่เก่าออกมาได้ ไม่ใช่แค่ “ดูรก”
- “double-render ของ StrictMode แปลว่า component ของฉันรันสองครั้งใน production ด้วย” มีแค่ตอน development — production render และรัน effect ครั้งเดียวเสมอ (ดู proof เรื่อง effect timing ในบท reconciliation)
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ generate มา update state ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- object หรือ array ถูก mutate ในที่เดิม (
.push,.sort(), assign property ตรง ๆ) ก่อนส่งให้ setter แทนที่จะ spread เป็น reference ใหม่หรือเปล่า? บั๊กนี้มองไม่เห็นจนกว่าจะสังเกตว่า UI ไม่ update - setter ตัวเดิมถูกเรียกมากกว่าหนึ่งครั้งใน function เดียว ด้วย form value (
setX(x + 1)) แทน form updater หรือเปล่า? ทุก call เห็น snapshot เดียวกัน - updater function ที่ส่งให้
setXมี side effect ไหม (log, network call, mutate ตัวแปรนอก)? ต้อง pure และ double-invoke ของ StrictMode ตอน dev จะเรียกมันสองครั้ง - มีโค้ดในตัว render (ไม่ใช่ใน event handler หรือ
useEffect) ที่อ่านค่าที่เปลี่ยนได้ระหว่างการเรียกแต่ละครั้งไหม —Date.now(),Math.random(), ตัวแปรระดับ module ที่ mutable — หรือเขียน DOM ตรง ๆ? - การเรียกอย่าง
setCount(sameValueAsNow)ถูกมองว่า “ต้อง trigger render แน่นอน” ทั้งที่จริง ๆ มันเป็น no-op เพราะ Object.is bail-out หรือเปล่า?
🐛 หา bug ในโค้ดนี้
function Cart({ initialItems }) {const [items, setItems] = useState(initialItems);
function markAllPacked() { items.forEach((item) => { item.packed = true; }); setItems(items);}
return ( <div> <button onClick={markAllPacked}>Mark all packed</button> <ul> {items.map((item) => ( <li key={item.id}>{item.name} — {item.packed ? 'packed' : 'not packed'}</li> ))} </ul> </div>);}เฉลย
markAllPacked mutate ทุก item ในที่เดิม (item.packed = true) แล้วเรียก setItems(items) ด้วย array reference เดิม ที่มันมีอยู่แล้ว check Object.is ของ React เห็น reference เดิมเป๊ะแล้ว bail-out — ไม่มี re-render เกิดขึ้น พิสูจน์แล้วข้างบนด้วยกรณีเทียบเท่าแบบย่อ: ข้อมูลจริง ๆ เปลี่ยนไปแล้ว (ทุก item มี packed: true ในหน่วยความจำตอนนี้) แต่หน้าจอยังโชว์ “not packed” ค้างจนกว่า state อื่นที่ไม่เกี่ยวข้องจะบังคับให้ re-render ซึ่งตอนนั้นมันจะ โผล่ โชว์ข้อมูลที่ mutate ไปแล้วทันที ดูเหมือนมี update แบบสุ่มที่ไม่เกี่ยวข้องมาทำให้เกิด ทางแก้คือ array ใหม่กับ item object ใหม่: setItems(items.map((item) => ({ ...item, packed: true })))