useState & Updates
พื้นฐานแบบเป๊ะ ๆ
หัวข้อที่มีชื่อว่า “พื้นฐานแบบเป๊ะ ๆ”useState คืนค่าเป็นคู่: value ปัจจุบันสำหรับ render นี้ และ setter ที่ schedule ให้ re-render ด้วย value ใหม่
const [count, setCount] = useState(0);// count → the value for THIS render (a snapshot)// setCount(next) → schedule a re-render where count is `next`การเรียก setter ไม่เปลี่ยน count ทันที — แต่เป็นการขอให้ React render อีกครั้ง ใน render ถัดไป useState(0) จะคืน value ที่อัปเดตแล้ว (บทเรียน “state เป็น snapshot” ใน Foundations คือ mental model ส่วนบทนี้คือกลไก)
Batching: หลาย setState, render เดียว
หัวข้อที่มีชื่อว่า “Batching: หลาย setState, render เดียว”React batch การอัปเดต state ที่เกิดใน event เดียวกัน หลายการเรียก setter ใน handler เดียวจะได้ re-render ครั้งเดียว ไม่ใช่ครั้งต่อการเรียก
function handleClick() { setCount(c => c + 1); setFlag(true); setName("Ada"); // → ONE re-render, with all three updates applied. React batches them.}ตั้งแต่ React 18 การ batch นี้ครอบคลุมการอัปเดตใน promise, timeout และ native event handler ด้วย — batch เกิดอัตโนมัติทุกที่ render น้อยลง UI สอดคล้องกัน
พิสูจน์: batching อยู่รอดข้าม await
หัวข้อที่มีชื่อว่า “พิสูจน์: batching อยู่รอดข้าม await”“อัตโนมัติทุกที่” รวมถึงการอัปเดต state ที่เกิด หลัง await ด้วย — ไม่ใช่แค่การอัปเดตที่ยิงแบบ synchronous ใน handler เชื่อเฉย ๆ ก็ได้แต่นึกภาพยาก เลยพิสูจน์ด้วย render counter:
import { useState } from 'react';
function Counter({ onRender }: { onRender: () => void }) { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); onRender();
async function handleClickAsync() { await Promise.resolve(); // yield once // Both of these happen AFTER an await, outside any React event handler // in the traditional sense — React 18+ still batches them into ONE render. setCount((c) => c + 1); setFlag((f) => !f); }
return ( <button onClick={() => { void handleClickAsync(); }}> {count}-{String(flag)} </button> );}
export default Counter;import { useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Counter({ onRender }: { onRender: () => void }) { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); onRender();
async function handleClickAsync() { await Promise.resolve(); // yield once setCount((c) => c + 1); setFlag((f) => !f); }
return ( <button onClick={() => { void handleClickAsync(); }} > {count}-{String(flag)} </button> );}
test('two setState calls after an await are batched into a single render (React 18+)', async () => { let renders = 0; const { getByRole } = render(<Counter onRender={() => renders++} />); const baseline = renders; // includes StrictMode's extra dev render, if any
await act(async () => { getByRole('button').click(); });
expect(getByRole('button').textContent).toBe('1-true'); // Exactly one additional render happened for BOTH state updates together, // not one render per setState call. expect(renders - baseline).toBe(1);});✓ two setState calls after an await are batched into a single render (React 18+)
Test Files 1 passed (1) Tests 1 passed (1)ถ้า React batch แค่การอัปเดตแบบ synchronous ใน handler เท่านั้น (พฤติกรรมก่อน 18) test นี้จะเห็น 2 render หลังคลิก ไม่ใช่ 1 release note ของ React 18 ปี 2022 อธิบายการเปลี่ยนแปลงนี้ตรง ๆ: ก่อน React 18 มีแค่การอัปเดตใน React event handler เท่านั้นที่ batch — การอัปเดตใน setTimeout, promise และ native event handler ต่างก็ trigger render ของตัวเอง ตั้งแต่ 18 ทั้งหมดนี้ถูก batch ด้วย
Updater function เทียบกับ value
หัวข้อที่มีชื่อว่า “Updater function เทียบกับ value”ส่ง value เมื่อ state ถัดไปไม่ได้ขึ้นกับค่าก่อนหน้า ส่ง updater function เมื่อขึ้นกับค่าก่อนหน้า — React รัน updater ตามลำดับกับค่า pending ล่าสุด
setCount(count + 1); // value: uses the snapshot; three of these still add 1 totalsetCount(c => c + 1); // updater: uses the pending value; three of these add 3flowchart LR start["pending = 0"] --> u1["c => c + 1 → 1"] u1 --> u2["c => c + 1 → 2"] u2 --> u3["c => c + 1 → 3"] u3 --> done["next render: count = 3"]
หลักง่าย ๆ: ถ้าคุณเรียก setter ตัวเดิมมากกว่าหนึ่งครั้งใน handler หรือค่าใหม่คำนวณจากค่าเก่า ให้ใช้ updater form
พิสูจน์: Strict Mode เรียก updater สองครั้งตอน dev — แต่ commit ครั้งเดียว
หัวข้อที่มีชื่อว่า “พิสูจน์: Strict Mode เรียก updater สองครั้งตอน dev — แต่ commit ครั้งเดียว”<StrictMode> ตอน dev ตั้งใจเรียก updater function สองครั้ง เพื่อช่วยให้คุณสังเกตเห็น updater ที่ไม่ pure (มี side effect เช่น log หรือ mutate อะไรนอก scope ของมัน) จุดนี้อ่านผิดง่ายมากว่า “state เพิ่มขึ้น 2” — ไม่จริง มีแค่ผลลัพธ์ของหนึ่งในสอง call ที่ถูกเก็บไว้
import { StrictMode, useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Counter({ log }: { log: number[] }) { const [count, setCount] = useState(0); return ( <button onClick={() => { // Updater function — StrictMode (dev only) calls it TWICE to help // you spot impure updaters, but only commits the result once. setCount((c) => { log.push(c); return c + 1; }); }} > {count} </button> );}
test('StrictMode invokes an updater function twice in dev, but only commits once', () => { const log: number[] = []; const { getByRole } = render( <StrictMode> <Counter log={log} /> </StrictMode>, ); act(() => { getByRole('button').click(); }); // Called with the same pending value twice (0, 0) — proves the double // invoke, not a real double-increment. expect(log).toEqual([0, 0]); expect(getByRole('button').textContent).toBe('1');});
test('without StrictMode, the same updater runs exactly once', () => { const log: number[] = []; const { getByRole } = render(<Counter log={log} />); act(() => { getByRole('button').click(); }); expect(log).toEqual([0]); expect(getByRole('button').textContent).toBe('1');});✓ StrictMode invokes an updater function twice in dev, but only commits once✓ without StrictMode, the same updater runs exactly once
Test Files 1 passed (1) Tests 2 passed (2)log พิสูจน์ตรง ๆ: ทั้งสอง call ได้ค่า pending เดียวกันคือ 0 ไม่ใช่ 0 แล้ว 1 — React ทิ้งผลลัพธ์ของ call แรกและเก็บแค่ call ที่สอง ดังนั้น count ที่ commit จริงคือ 1 ไม่ใช่ 2 นี่คือเหตุผลที่ updater function ต้อง pure (ห้าม log หรือ mutate อะไรนอกตัวมันเองใน code จริง) — double-invoke ของ Strict Mode มีไว้เพื่อเปิดโปง updater ที่ไม่ pure โดยเฉพาะ
Lazy initialization
หัวข้อที่มีชื่อว่า “Lazy initialization”argument ของ useState ถูกใช้แค่ใน render แรก เท่านั้น แต่ React ยัง evaluate argument นั้นทุก render (แล้วทิ้ง) ถ้าการคำนวณ initial value แพง ให้ส่ง function — React จะเรียกแค่ครั้งเดียว
// ❌ createInitialTodos() runs on EVERY render (result discarded after the first).const [todos, setTodos] = useState(createInitialTodos());
// ✅ Lazy: the function runs only on the first render.const [todos, setTodos] = useState(() => createInitialTodos());state ต้อง immutable
หัวข้อที่มีชื่อว่า “state ต้อง immutable”React ตัดสินใจว่าจะ re-render ไหมโดยเทียบ reference การ mutate object หรือ array ในที่เดิมทำให้ reference เดิม React จึงอาจข้ามการอัปเดต — และทำลาย snapshot model สร้าง value ใหม่เสมอ
// ❌ Mutation — same reference, unreliable render.user.name = "Ada";setUser(user);
// ✅ New object.setUser({ ...user, name: "Ada" });
// ✅ New arrays for add / remove / update.setItems([...items, newItem]); // addsetItems(items.filter(i => i.id !== id)); // removesetItems(items.map(i => i.id === id ? { ...i, done: true } : i)); // updateสำหรับ state ที่ nested ลึก การ spread จะเริ่มยุ่ง — นั่นคือสัญญาณว่าควร flatten รูปทรง, ใช้ library อย่าง Immer, หรือหันไปใช้ useReducer (บทเรียนถัดไป)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มองการเรียก setCount(...) ทุกครั้ง — value หรือ updater — เป็นการ push item เข้า queue ของ render cycle นี้ ไม่ใช่การ assign ทันที React ประมวลผล queue ทั้งก้อนครั้งเดียว ตามลำดับ ก่อน render ถัดไปพอดี: entry แบบ value แทนที่ pending value ตรง ๆ, entry แบบ updater แปลงค่าที่ pending อยู่ตอนนั้น นั่นคือเหตุผลที่ updater สามตัวต่อกันได้ +3 ในขณะที่ value สามตัวยุบเหลือ +1 — และ “batching” จริง ๆ ก็แค่ “queue ถูก flush ครั้งเดียว” ไม่ว่าการเรียกจะเกิดแบบ synchronous ใน handler หรือหลัง await
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
Updater function setX(x => ...) | ถูกต้องเสมอเมื่อเรียก setter ตัวเดิมหลายครั้ง หรือคำนวณค่าใหม่จากค่าเก่า | ต้อง pure — Strict Mode เรียกมันสองครั้งตอน dev เพื่อบังคับข้อนี้ |
Value form setX(next) | อ่านง่ายกว่าสำหรับการอัปเดตเดี่ยวที่เป็นอิสระ | ผิดแบบเงียบ ๆ ถ้าเรียกมากกว่าหนึ่งครั้งต่อ render ด้วย value ที่ closure ค้างไว้ |
Lazy init useState(() => expensive()) | การคำนวณที่แพงรันแค่ครั้งเดียวตลอดไป | ต้องจำห่อ function เพิ่มอีกชั้น |
| Mutate state ตรง ๆ | บางครั้งดูเหมือน “ทำงานได้เฉย ๆ” | React อาจข้าม re-render ไปเลย และบั๊กมองไม่เห็นจนกว่าจะเห็น |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “เรียก
setCount(count + 1)สามครั้งจะบวก 3” มันบวกแค่ 1 — ทั้งสาม call เห็นcountsnapshot เดียวกันจาก render นี้ พิสูจน์ด้านล่างด้วยคู่ fail/pass - “double-invoke ของ Strict Mode แปลว่า state เปลี่ยนสองครั้ง” มันแปลว่า updater function ของคุณรันสองครั้งเพื่อเช็ก purity มีแค่ผลลัพธ์เดียวที่ถูก commit การเปลี่ยน state ที่เห็นจริงยังเป็นแค่หนึ่งขั้นตอน
- “batching เกิดแค่ใน
onClickและเพื่อน ๆ” ตั้งแต่ React 18 มันเกิดหลังawait, ในsetTimeoutและใน promise callback ด้วย — พิสูจน์แล้วด้านบน - “mutate object แล้วเรียก setter ด้วย reference เดิมก็ยังทำงานได้ปกติ แค่ผิด best practice” มันอาจข้าม re-render แบบเงียบ ๆ ได้เพราะ bail-out check ของ React คือการเทียบ reference — นี่คือบั๊กด้านความถูกต้องจริง ไม่ใช่แค่เรื่อง style
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ generate มาเรียก setter ของ useState ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- setter ตัวเดิมถูกเรียกมากกว่าหนึ่งครั้งใน function เดียว ด้วย form value (
setX(x + 1)) แทน form updater หรือเปล่า? นั่นคือบั๊กจริง ไม่ใช่แค่ทางเลือก style - updater function ที่ส่งให้
setXมี side effect ไหม (log, network call, mutate ตัวแปรนอก)? Strict Mode จะเรียกมันสองครั้งตอน dev แล้วเผยบั๊กออกมา - state ที่ derive จาก state/prop อื่นถูกอัปเดตใน effect ทั้งที่คำนวณตอน render ได้เลยไหม (ดูบท
useEffect)? - object หรือ array ถูก mutate ในที่เดิม (
.push, assign property ตรง ๆ) ก่อนส่งให้ setter แทนที่จะ spread เป็น reference ใหม่หรือเปล่า? - code ที่อัปเดต state หลัง
awaitสมมติว่าต้องช่วย batch เอง (เช่นห่อด้วยunstable_batchedUpdates) หรือเปล่า? ตั้งแต่ React 18 ไม่จำเป็นแล้ว — เรียกตรง ๆ ก็ batch อยู่แล้ว
🐛 หา bug ในโค้ดนี้
function Counter() {const [count, setCount] = useState(0);return ( <button onClick={() => { setCount(count + 1); setCount(count + 1); setCount(count + 1); }} > {count} </button>);}เฉลย
ทั้งสาม call อ่าน count จาก snapshot เดียวกันของ render นี้ — คลิกครั้งเดียวไม่ได้บวก 3 มันบวก 1 เพราะ count + 1 ถูกคำนวณสามครั้งกับค่าเริ่มต้นเดียวกันก่อนที่ตัวไหนจะ commit พิสูจน์แล้วในโพรบ: component นี้ตัวเป๊ะ ๆ ได้ '1' หลังคลิกครั้งเดียว ไม่ใช่ '3' ทางแก้คือ updater form, setCount(c => c + 1) สามครั้งติดกัน — แต่ละอันอ่านค่า pending ที่ call ก่อนหน้าทิ้งไว้ใน batch เดียวกัน จึงต่อกันได้ +3 ถูกต้อง