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

State Snapshot & Purity

นี่คือเรื่องที่คนเข้าใจผิดมากที่สุดใน 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:

src/__tests__/state-is-a-snapshot.test.tsx
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)

React ตัดสินว่าการเรียก setState ต้อง render ใหม่ไหมโดยเทียบค่าใหม่กับค่าปัจจุบันด้วย Object.is (การเทียบ same-value แบบเดียวกับ === แต่แก้ edge case NaN/-0) ถ้าเหมือนกัน React จะ bail-out และข้าม re-render ของ component นั้นไปเลย — การเรียกนั้นไม่ใช่งานเปล่าที่กลายเป็น render ที่ไม่ทำอะไร แต่เป็นการข้าม render จริง ๆ:

src/__tests__/object-is-bailout.test.tsx
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 เป็น snapshot ที่ React เทียบด้วย identity คุณจึง ต้องไม่ mutate state การ mutate object หรือ array ในที่เดิมยังคง reference เดิม — ดังนั้น check Object.is ข้างบนเห็นว่าไม่เปลี่ยนแล้ว bail-out ทั้งที่ข้อมูลข้างในเปลี่ยนไปจริง ๆ:

src/__tests__/mutation-does-not-rerender.test.tsx
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 ของ 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"]
render ที่ pure เข้า side effect ออก

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 ไม่เปลี่ยน

ใน 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)

ทุก 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 เห็น count snapshot เดียวกันจาก 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)

เมื่อ 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 })))

ใน event handler เดียว เรียก setCount(count + 1) สามครั้งโดย count = 0 ผลลัพธ์คืออะไร?
จะ increment state สามครั้งใน handler เดียวให้ถูกต้องอย่างไร?
ทำไมต้องไม่ mutate state (เช่น todos.push(x); setTodos(todos))?
การ double-render ของ StrictMode มีไว้ทำอะไร?
คลิกปุ่มนี้ทำให้เกิด render เพิ่มกี่ครั้ง?
function Counter() {
  const [count, setCount] = useState(5);
  return <button onClick={() => setCount(5)}>{count}</button>;
}
หลังเรียกอันนี้ UI โชว์ทุก item เป็น packed ทันทีไหม?
function markAllPacked() {
  items.forEach((item) => { item.packed = true; });
  setItems(items);
}