โหมดมืด
บทที่ 2 — Hooks เชิงลึก (useEffect, useMemo, useCallback, useRef, useContext, custom hooks)
รู้จัก hook ตัวอื่น ๆ ของ React (นอกจาก
useStateที่บทแล้วเรียนไป) — จัดการ side effect, optimize re-render, แชร์ state ข้าม component, และสร้าง hook ของตัวเอง
🟢 ระดับ: มือใหม่ — แต่ §8.5 (React 19 hooks ใหม่) แตะของระดับสูง ข้ามไปก่อนได้ ค่อยกลับมาอ่านหลังผ่านบท 11
บทที่แล้วเรารู้จัก useState — บทนี้เราจะเรียน hook อื่น ๆ ที่ใช้บ่อยมาก + เข้าใจ "กฎของ hook"
หลังจบบท คุณจะ:
- เข้าใจ render lifecycle (วงจรชีวิตของ component — ตั้งแต่ mount ใส่หน้าจอ → update เมื่อ state เปลี่ยน → unmount ออกจากหน้าจอ) ของ React
- ใช้ useEffect ทำ side effect (= งานนอกการคำนวณ JSX เช่น fetch, timer, subscribe — รายละเอียด §2)
- ใช้ useMemo / useCallback memoize (= จำผลลัพธ์เก่าไว้ไม่คำนวณซ้ำ) เพื่อลด re-render
- ใช้ useRef เก็บค่าโดยไม่ trigger render (= ไม่สั่งให้วาดใหม่)
- ใช้ useContext share state (= แชร์ข้อมูล) ข้าม component หลายระดับโดยไม่ส่ง prop ต่อ ๆ กัน
- สร้าง custom hook ของตัวเอง
📖 คำศัพท์รวมของบท (มือใหม่อ่านก่อน):
- hook = function ของ React ที่ขึ้นต้นด้วย
use(useState,useEffect, ฯลฯ) — มัน "ผูก" component กับระบบของ React (state, lifecycle, context)- side effect = งานที่กระทบโลกภายนอก function เช่น fetch API, ตั้ง timer, log, แก้ DOM, เขียน localStorage — ห้ามทำใน body ของ component ตรงๆ ต้องอยู่ใน useEffect
- memoize = "จำผลลัพธ์เก่า" — ถ้า input เหมือนเดิม ก็คืนผลเก่าโดยไม่คำนวณใหม่
- dependency array =
[a, b]ในuseEffect(() => {...}, [a, b])— รายการของค่าที่ effect "ขึ้นกับ" → ถ้าค่าเปลี่ยน effect รันใหม่- closure = ความสามารถของ function ใน JS ที่ "จำ" ตัวแปรรอบตัวตอนสร้างได้ (สำคัญในการเข้าใจ useEffect/useCallback) — ตัวอย่าง:
function makeCounter(){ let n = 0; return () => ++n; }— function ที่ return ออกไป "จำ"nไว้ได้ถึงแม้makeCounterจบไปแล้ว (nคือตัวแปรที่ถูก "จำ" ไว้ใน closure,++คือเพิ่มทีละ 1)- reference equality (=== กับ object) = ใน JS
{} === {}ได้falseเสมอ เพราะเทียบ "ตำแหน่งใน memory" ไม่ใช่เนื้อข้างใน — สำคัญสำหรับ useMemo/useCallback💡 TS syntax ที่ใช้ในบท (ทบทวนจากบท 0):
: type= บอกชนิด,<T>= generic (รับ type มา) — ถ้าใช้ JS เปล่า ๆ ตัดส่วนเหล่านี้ออกได้
1. กฎของ Hooks
มี 2 กฎเท่านั้น แต่ห้ามฝ่าฝืน:
กฎ 1: เรียก hook ที่ top level เท่านั้น
❌ ห้าม เรียก hook ใน:
- if / else
- loop
- function ภายใน
- หลัง early return
tsx
function Bad({ isLoggedIn }: { isLoggedIn: boolean }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // ❌ เรียกใน if
}
if (!isLoggedIn) return null;
const [data, setData] = useState(null); // ❌ หลัง early return
for (let i = 0; i < 5; i++) {
const [x, setX] = useState(0); // ❌ ใน loop
}
}✅ ทำ: เรียก hook ที่ top level เสมอ — ใน body ของ component
tsx
function Good({ isLoggedIn }: { isLoggedIn: boolean }) {
const [user, setUser] = useState(null); // ✅ top level
const [data, setData] = useState(null); // ✅
if (!isLoggedIn) return null; // OK — return หลังเรียก hook แล้ว
return <div>{data}</div>;
}กฎ 2: เรียก hook ใน component หรือ custom hook เท่านั้น
❌ ห้ามเรียกใน:
- regular function
- class component
✅ เรียกได้ใน:
- function component
- custom hook (function ที่ขึ้นต้นด้วย
use)
ทำไมต้องมีกฎ?
React track hook ตาม ลำดับการเรียก — ไม่ใช่ตามชื่อ
นึกภาพ React ถือกระดาษบันทึกที่มีช่องเรียงตามลำดับ
render ครั้งแรก: เขียน useState(0) ลงช่อง 1, เขียน useState('') ลงช่อง 2
render ครั้งต่อไป: React ไปเปิดช่อง 1 เจอ 0 กับช่อง 2 เจอ '' — รู้ทันทีว่าตัวแปรไหนเก็บค่าอะไร
ปัญหาเกิดเมื่อคุณใส่ hook ใน if แล้ว if นั้นเป็น false — ช่อง 1 หายไปจากกระดาษ
ผลคือ: render ครั้งถัดไป React เปิดช่อง 1 เจอข้อมูลเดิมของช่อง 2 แทน → ค่าสลับกัน → bug ลึกมากจนหาสาเหตุไม่เจอ
Mental model: React เก็บ "ช่อง" (slot) เป็นรายการเรียงตามลำดับการเรียก hook (นับช่องแรกเป็นช่อง 0 ตามธรรมเนียมการนับของโปรแกรมเมอร์ — เรียกครั้งที่ 1 = ช่อง 0, ครั้งที่ 2 = ช่อง 1) ทุก render ต้องเจอ hook ในลำดับเดิม React จึงรู้ว่า useState ตัวไหนเก็บ count ตัวไหนเก็บ name
tsx
function Bad({ show }: { show: boolean }) {
if (show) {
const [a, setA] = useState(0); // render 1 (show=true): ช่อง 0 = a
}
const [b, setB] = useState(0); // render 1: ช่อง 1 = b
}
// render 2 (show=false): ช่อง 0 ที่เคยเป็น a กลายเป็น b
// ช่อง 1 ที่เคยเป็น b หายไป → React ใช้ค่าผิด → bug📖 รายละเอียดโครงสร้างภายในของ hook (React เก็บเป็นโครงสร้างอะไร) — อยู่ในบท 14 ตอนนี้แค่จำว่า "เป็น slot เรียงตามลำดับการเรียก" ก็พอ
ใช้ ESLint plugin eslint-plugin-react-hooks (ESLint = เครื่องมือตรวจโค้ดอัตโนมัติ มากับ Vite template; plugin = ส่วนเสริมที่เพิ่มกฎตรวจสอบพิเศษ) — Vite template react-ts ที่สร้างในบท 1 เปิดใช้ plugin นี้ให้แล้วอัตโนมัติ ไม่ต้องทำอะไรเพิ่ม — ถ้าผิดกฎ เส้นคลื่นสีเหลือง/แดงจะขึ้นใต้โค้ดใน VS Code (hover ดูคำเตือน)
2. useEffect — side effect
2.1 Effect คืออะไร
"Side effect" = อะไรที่ไม่ใช่ render UI:
- Fetch data
- Subscribe / listen event
- Set timer
- Manipulate DOM โดยตรง (rare)
- Log analytics
2.2 Syntax
tsx
useEffect(() => {
// โค้ดของ effect (งาน side effect ที่อยากทำ)
return () => {
// cleanup (เก็บกวาด, ใส่หรือไม่ก็ได้) — รันก่อน effect รอบถัดไป + ตอน component ถูกถอด (unmount)
};
}, [dependencies]); // array ของค่าที่ effect ขึ้นกับ (ถ้าค่านี้เปลี่ยน effect ถึงจะรันใหม่)2.3 ตัวอย่าง: fetch ข้อมูล
⚠️ อ่านก่อน: ตัวอย่างนี้สอนว่า useEffect ทำงานยังไง — ไม่ใช่ วิธีที่แนะนำให้ fetch ข้อมูลในงานจริงปี 2026
- React team เองก็ระบุใน react.dev ("You Might Not Need an Effect") ว่า fetch-in-effect แบบนี้เป็น anti-pattern (รูปแบบที่ไม่แนะนำ) สำหรับเกือบทุกกรณี
- ในงานจริงใช้: TanStack Query (บท 4),
use()+ Suspense (§8.5.2), หรือ Server Component (บท 11)- อ่านส่วนนี้เพื่อเข้าใจกลไก แต่จำไว้ว่า production จะใช้ library ที่จัดการ cache/dedup/race ให้
tsx
type User = { id: string; name: string }; // เพิ่ม type นี้ในไฟล์เดียวกัน (หรือ import จาก types.ts)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`); // 4xx/5xx → throw ให้ catch จับ
return r.json();
})
.then(data => {
setUser(data);
setLoading(false);
})
.catch(() => setLoading(false)); // กรณี error ให้หยุด loading
}, [userId]); // run ใหม่เมื่อ userId เปลี่ยน
if (loading) return <p>Loading...</p>;
if (!user) return <p>Not found</p>;
return <div>{user.name}</div>;
}2.4 Dependency array
tsx
// [] = run แค่ครั้งเดียวตอน mount
useEffect(() => {
console.log("mounted");
}, []);
// [x, y] = run เมื่อ mount + เมื่อ x หรือ y เปลี่ยน
useEffect(() => {
console.log("x or y changed");
}, [x, y]);
// ไม่ใส่ array = run ทุก render (เกือบจะเป็น bug เสมอ!)
useEffect(() => {
console.log("every render");
});
// ถ้าอยากให้ code รันทุก render — code นั้นน่าจะอยู่ใน body ของ component
// หรือใน abstraction อื่น ไม่ใช่ใน useEffect2.5 Cleanup (เก็บกวาด)
📖 mount = component ถูกใส่เข้า DOM และแสดงบนหน้าจอครั้งแรก | unmount = component ถูกถอดออกจากหน้าจอ (เช่น navigate ไปหน้าอื่น หรือ conditional render เป็น false)
ใช้เมื่อ effect "เปิด" อะไรไว้ ต้อง "ปิด" — cleanup function จะรันตอน unmount (เมื่อ component ถูกถอดออกจากหน้าจอ) และก่อน effect รันรอบใหม่:
tsx
useEffect(() => {
const timer = setInterval(() => {
console.log("tick");
}, 1000);
return () => clearInterval(timer); // cleanup ตอน unmount
}, []);tsx
useEffect(() => {
const handler = (e: KeyboardEvent) => console.log(e.key);
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);2.6 ⚠️ Strict Mode — effect run 2 รอบใน dev
ใน dev (StrictMode), React run effect 2 รอบเพื่อจับ bug — โดย mount (ใส่เข้า DOM) → cleanup → mount (2 รอบ) → unmount (ถอดออก):
text
mount → cleanup → mount (2 รอบใน dev เท่านั้น)bug ที่ Strict Mode ช่วยจับ: เช่น ถ้า effect subscribe event โดยไม่มี cleanup → event handler จะถูกเรียก 2 ครั้ง ทำให้เห็นปัญหาตั้งแต่ dev ก่อน deploy
ใน production run รอบเดียว — กฎคือ เขียน effect ให้ทน cleanup-remount
⚠️ ห้ามคิดว่า effect run แค่ครั้งเดียว — ใส่ cleanup เสมอ
2.7 ⚠️ Race condition (สภาพแย่งกัน — เมื่อมีหลาย async ทำงานพร้อมกัน ผลลัพธ์ออกผิดเพราะลำดับมาไม่ตรง) ใน fetch
💡 มือใหม่ข้ามก่อนได้ — ส่วนนี้อธิบาย edge case ที่เจอในงานจริง กลับมาอ่านหลังทำ checkpoint 2.1 แล้ว ในทางปฏิบัติใช้ TanStack Query (บท 4) จัดการให้
tsx
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
}, [userId]);ถ้า user เปลี่ยน userId เร็ว ๆ:
- request 1 ส่งสำหรับ id=1
- request 2 ส่งสำหรับ id=2
- response 2 มาก่อน
- response 1 มาทีหลัง → set state ของ user 1 (ผิด!)
แก้ด้วย flag:
tsx
useEffect(() => {
let cancelled = false; // flag ว่า request นี้ถูกยกเลิกแล้วไหม
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
if (!cancelled) setUser(data); // ตั้งค่าเฉพาะถ้ายังไม่ถูกยกเลิก
});
return () => { cancelled = true; }; // cleanup: ตั้ง flag ว่ายกเลิกแล้ว ป้องกัน setState หลัง unmount
}, [userId]);หรือใช้ AbortController (ตัวยกเลิก request — ใช้ยกเลิก fetch ที่ยังค้างอยู่ก่อนเริ่ม fetch ใหม่):
tsx
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/users/${userId}`, { signal: ctrl.signal })
.then(r => r.json())
.then(setUser);
return () => ctrl.abort();
}, [userId]);(บทที่ 4 จะใช้ TanStack Query ที่จัดการเรื่องนี้ให้ — ไม่ต้องเขียนเอง)
2.8 ⚠️ ต้องใส่ dependency ครบ (Exhaustive deps — ครบทุก dependency กฎที่บังคับให้ใส่ทุกตัวแปรที่ใช้ใน effect ลงใน dependency array)
ESLint rule react-hooks/exhaustive-deps บังคับให้ใส่ทุก variable ที่ใช้ใน effect เป็น dependency — กฎนี้ช่วยกัน stale closure (closure ที่ "จำ" ค่าเก่าค้างไว้ — ดูนิยาม closure ใน §0 คำศัพท์รวมของบท) ซึ่งเกิดเมื่อ deps ขาด
สมมุติ count เปลี่ยนเป็น 5 แล้ว แต่ deps array เป็น [] — effect ถ่ายภาพค่า count=0 จากตอน mount ไว้ใน closure ไม่ว่า count จะเปลี่ยนกี่ครั้ง console.log ก็จะพิมพ์ 0 เสมอ เพราะ function ใน effect "จำ" ค่า count จากวันแรกที่สร้าง — ไม่ใช่ค่าปัจจุบัน ใส่ [count] ใน deps → effect สร้างใหม่ทุกครั้งที่ count เปลี่ยน → จำค่าล่าสุดถูกต้อง
tsx
function Bad() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(count);
}, []); // ❌ ESLint warn: count is missing
useEffect(() => {
console.log(count);
}, [count]); // ✅
}ถ้าตั้งใจไม่ใส่ — disable rule + comment อธิบาย
2.9 ❌ อย่าใช้ effect "sync state กับ state"
tsx
// ❌
const [a, setA] = useState(0);
const [doubled, setDoubled] = useState(0);
useEffect(() => {
setDoubled(a * 2);
}, [a]);ปัญหา: double render — render ที่ 1 set a, effect run set doubled, render ที่ 2
แก้: compute ระหว่าง render
tsx
const [a, setA] = useState(0);
const doubled = a * 2; // ✅ ไม่ต้องเก็บ state ซ้ำถ้าคำนวณแพง → ใช้ useMemo
3. useMemo — memoize ค่า
ทุกครั้งที่ state เปลี่ยน React จะวาด component ใหม่ทั้งหมด (= re-render) และรัน function ทุกบรรทัดใน body ใหม่ตั้งแต่ต้น
ตัวอย่างเช่น ถ้า body ของ component มีการ filter(10,000 รายการ) React ก็จะรัน filter นั้นใหม่ทุกครั้ง ถึงแม้ตัว filter จะยังกรองเงื่อนไขเดิม
useMemo = บอก React ว่า "ถ้า input เหมือนเดิม คืนผลเก่าได้เลย ไม่ต้องคำนวณซ้ำ"
⚠️ อ่านก่อน — สำคัญสำหรับปี 2026: React Compiler (§8.5.5) ใส่ memoization ให้อัตโนมัติ — เกือบทุกกรณีคุณ ไม่ต้อง เขียน
useMemo/useCallback/React.memoเอง
- ทำไมต้องอ่านถ้าไม่ต้องเขียนเอง? — เพื่อเข้าใจว่า React ทำอะไรให้ "ใต้พื้น" ถ้าเปิด React Compiler แล้วไม่ต้องเขียนเองก็ได้ แต่ถ้าไม่ได้เปิด Compiler หรือเจอ edge case ก็ยังต้องใช้
- อ่านส่วนนี้เพื่อ เข้าใจว่า memoization คืออะไร และ Compiler ทำอะไรให้ใต้พื้น
- ในงานจริงที่เปิด Compiler — เขียนแบบธรรมดา ปล่อยให้ Compiler จัดการ
- เขียน
useMemo/useCallbackมือเฉพาะ: hot spot (จุดที่ทำงานหนักจริง ๆ วัด performance แล้วช้าจริง) หรือ edge case ที่ Compiler ไม่ครอบคลุม (React Compiler จะ skip optimization ถ้า component มี side effect ใน render หรือ mutation)
3.1 Use case: คำนวณแพง
tsx
// ข้อมูลจำนวนมาก (ตัวอย่าง: array 10,000 รายการ)
const bigData = Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
// ฟังก์ชัน filter ที่ใช้เวลาพอสมควร
function expensiveFilter(data: typeof bigData, query: string) {
return data.filter(item => item.name.includes(query));
}
function Slow() {
const [count, setCount] = useState(0);
const [filter, setFilter] = useState("");
const filtered = expensiveFilter(bigData, filter); // ❌ run ทุก render
return <div>...</div>;
}ทุกครั้งที่ count เปลี่ยน — render ใหม่ — รัน expensiveFilter ใหม่ (แม้ filter ไม่เปลี่ยน)
แก้ด้วย useMemo:
tsx
const filtered = useMemo(
() => expensiveFilter(bigData, filter),
[filter] // recompute เมื่อ filter เปลี่ยนเท่านั้น (bigData คงที่จึงไม่ต้องใส่)
);— ผลถูก cache, recompute เฉพาะ deps เปลี่ยน
3.2 ⚠️ อย่าใช้ useMemo ทุกที่
useMemo เองมี overhead — ใช้เฉพาะเมื่อ:
- คำนวณแพงจริง ๆ (loop ใหญ่, sort ใหญ่)
- ส่ง prop ที่เป็น object/array ลง child ที่ memo
tsx
// ❌ overkill
const doubled = useMemo(() => count * 2, [count]);
// ✅
const doubled = count * 2;3.3 อีก use case: stable reference
tsx
function Parent() {
const [count, setCount] = useState(0);
// ❌ ทุก render สร้าง object ใหม่ → ChildMemo re-render ถึงแม้ value เท่าเดิม
const options = { sort: "asc", limit: 10 };
return <ChildMemo options={options} />;
}
// แก้:
const options = useMemo(() => ({ sort: "asc", limit: 10 }), []);4. useCallback — memoize function
ใน JavaScript ทุกครั้งที่ component render — function ที่เขียนใน body จะถูกสร้างขึ้นมาใหม่ทุกรอบ (เหมือนพิมพ์เอกสารใหม่ทุกครั้งแม้เนื้อหาเหมือนเดิมทุกตัวอักษร)
ถ้าคุณส่ง function นั้นไปให้ component ลูก ลูกจะคิดว่า "ได้รับ function ใหม่" (เพราะ reference ในหน่วยความจำเปลี่ยน) และ render ใหม่ทั้งที่ไม่มีอะไรเปลี่ยนจริง ๆ
useCallback แก้ปัญหานี้โดยคืน function เดิม (reference เดิม) ทุกรอบ ตราบใดที่ dependency ไม่เปลี่ยน — ลูก component จึงรู้ว่างานไม่เปลี่ยน ไม่ render ซ้ำ
💡
React.memoคืออะไร? — component ที่ถูก wrap ด้วยReact.memoจะ render ใหม่เฉพาะเมื่อ prop เปลี่ยนจริง ๆ รายละเอียดอยู่ใน §5 ถัดไป แต่ต้องรู้ก่อนเพราะuseCallbackมีประโยชน์หลักก็ตรงนี้
📖 สำหรับผู้สงสัย:
useCallback(fn, deps)ใต้พื้นทำงานเหมือนuseMemo(() => fn, deps)แค่เขียนสั้นกว่าสำหรับกรณี function โดยเฉพาะ
Use case
tsx
// สมมุติว่า ChildMemo ถูก wrap ด้วย React.memo (ดู §5 ถัดไป) เพื่อ skip re-render เมื่อ prop ไม่เปลี่ยน
function Parent() {
const [count, setCount] = useState(0);
// ❌ ทุก render สร้าง function ใหม่
const handleClick = () => console.log("click");
return <ChildMemo onClick={handleClick} />;
}ทุก render → handleClick เป็น function ใหม่ → ChildMemo re-render เพราะคิดว่า prop เปลี่ยน
แก้:
tsx
const handleClick = useCallback(() => {
console.log("click");
}, []);— function เดียวกันข้าม render
กฎ rule of thumb
ใช้ useCallback เมื่อ:
- ส่ง callback ลง child ที่
React.memo - ส่งเป็น dependency ของ effect ใน child
อย่าใช้พร่ำเพรื่อ — เปล่า ๆ ก็มี cost
5. React.memo — skip re-render ถ้า prop เหมือนเดิม
ปกติถ้า Parent render ใหม่ → component ลูกทุกตัวใน Parent จะ render ตามโดยอัตโนมัติ ถึงแม้ prop จะไม่เปลี่ยนเลย
React.memo = ติดป้ายให้ลูก component ว่า "วาดใหม่เฉพาะถ้า prop เปลี่ยนจริง ๆ"
ลองนึกภาพ: Parent มี counter ที่ขึ้นทุกวินาที แต่ลูก component รับแค่ value={10} (คงที่) โดยไม่มี React.memo ลูกจะ render ทุกวินาทีพร้อม Parent ถึงแม้จะไม่มีอะไรให้แสดงต่างออกไปก็ตาม
tsx
const ChildMemo = React.memo(({ value }: { value: number }) => {
console.log("Child rendered");
return <div>{value}</div>;
});ถ้า parent re-render แต่ value เท่าเดิม → ChildMemo skip re-render — ลองเปิด DevTools console ดู: กด counter ใน Parent กี่ครั้ง "Child rendered" ก็จะไม่ขึ้นตราบใดที่ value ยังเป็น 10 เหมือนเดิม
React.memo ใช้ shallow comparison (เปรียบเทียบแบบตื้น — เทียบแค่ระดับบนสุด ไม่ลึกเข้าไปใน object/array) — === กับทุก prop
💡 ทำไม object prop ถึงทำให้ re-render? ใน JavaScript
{ a: 1 } === { a: 1 }ได้falseเสมอ เพราะเทียบ "ตำแหน่งใน memory" ไม่ใช่เนื้อข้างใน — ดังนั้น object ใหม่ทุก render = prop "เปลี่ยน" ในสายตา React.memo
นั่นคือเหตุผลที่ object/function prop ต้อง stable (คงที่ — reference ไม่เปลี่ยนระหว่าง render โดยใช้ memoize)
6. useRef — เก็บค่าโดยไม่ trigger render
6.1 Use case 1: เข้าถึง DOM
tsx
function Input() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus(); // focus ตอน mount — `?.` = optional chaining (บท 0 §3.8): ถ้า inputRef.current เป็น null จะไม่เรียก focus() และไม่ error
}, []);
return <input ref={inputRef} />;
}6.2 Use case 2: เก็บค่าที่ไม่ trigger render
tsx
function Timer() {
const [count, setCount] = useState(0);
// ใน browser `setInterval` คืน number; ถ้าอยากให้ code portable (รันได้ทั้งใน browser และ Node.js ไม่ขึ้นกับ environment เดียว) ทั้ง browser/Node
// ใช้ ReturnType<typeof setInterval> แทน
// ⚠️ ถ้าเจอ TS error type mismatch กับ setInterval บอกว่า Timeout ไม่ใช่ number — โปรเจกต์คุณน่าจะมี @types/node ปนอยู่ (ทำให้ TS มองว่า setInterval คืนค่าแบบ Node แทนที่จะเป็น number ของ browser) ให้ใช้ ReturnType<typeof setInterval> แทน number เสมอเพื่อไม่ผูกกับ environment
const intervalRef = useRef<number | null>(null);
const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 1000);
};
const stop = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
return (
<>
<p>{count}</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}useRef return { current: value } — เปลี่ยน .current ไม่ trigger render
6.3 ความต่าง: state vs ref
| state | ref | |
|---|---|---|
| เปลี่ยน trigger render? | ✅ | ❌ |
| Sync หรือ async | async batch | sync immediate |
| ใช้เก็บอะไร | data ที่แสดงใน UI | DOM, timer, อะไรที่ไม่ส่งผลต่อ UI |
6.4 ส่ง ref ลง child component (React 19)
ใน React 19 ref เป็น prop ปกติ — รับมาเหมือน prop อื่นได้เลย:
⚠️ ต้องใช้ react@19 + @types/react@19 จริง ๆ — ถ้าโปรเจกต์ยังเป็น React 18 (ของเก่า หรือทำตาม tutorial เก่า) โค้ดด้านล่างจะขึ้น TypeScript error หรือ
refไม่ถูกส่งต่อ ให้เช็คpackage.jsonก่อนว่า"react": "19.x"หรือสูงกว่า
tsx
// React 19 — ref-as-prop (รูปแบบใหม่ที่แนะนำ)
// React.ComponentProps<'input'> = type ที่ React เตรียมไว้ให้ ดึง props ทั้งหมดของ <input> HTML tag (รวม ref) มาใช้อัตโนมัติ — ไม่ต้องเขียน type เอง
function MyInput({ ref, ...props }: React.ComponentProps<'input'>) {
return <input ref={ref} {...props} />;
}
// ใช้:
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <MyInput ref={inputRef} />;
}📖 เจอ
forwardRefใน tutorial เก่า? — คือรูปแบบของ React 18 ที่ยังใช้ได้ใน R19 แต่ไม่ต้องใช้แล้ว เขียนrefเป็น prop ตรง ๆ ได้เลย —forwardRefเป็น legacy code (โค้ดรุ่นเก่า) ที่เจอใน codebase ก่อน React 19 เท่านั้น
6.5 ❌ อย่าใช้ ref แทน state
tsx
const countRef = useRef(0);
function increment() {
countRef.current++; // ✅ value เพิ่ม
// แต่ UI ไม่อัพเดท!
}
return <p>{countRef.current}</p>; // ค่าไม่เปลี่ยนใน UIถ้าอยากให้ UI อัพเดท → ใช้ state
7. useContext — share state ไม่ต้อง prop drill (ส่ง prop ทะลุชั้น)
ปัญหา: prop drilling
เคยส่ง
userผ่าน 4 ชั้น component แค่เพื่อให้ component ลึก ๆ ใช้ไหม? — บทนี้แก้ปัญหานั้น
prop drilling = การส่ง prop ผ่านชั้น component หลายระดับเพียงเพื่อให้ component ลึก ๆ ใช้
tsx
<App> {/* มี user */}
<Layout> {/* รับ user → ส่งต่อ */}
<Header> {/* รับ user → ส่งต่อ */}
<UserMenu user={user} />
</Header>
</Layout>
</App>3 ชั้นแค่ส่ง prop ผ่าน — น่ารำคาญ
Context = "global state" สำหรับ subtree
tsx
// AuthContext.tsx
// ReactNode = type ของ children (อะไรก็ได้ที่ render เป็น UI ได้ — string, JSX, array, ฯลฯ)
import { createContext, useContext, useState, useMemo, ReactNode } from 'react';
type User = { id: string; name: string }; // สมมุติ — ปกติ import จากไฟล์ types
type AuthContextType = {
user: User | null;
login: (u: User) => void;
logout: () => void;
};
const AuthContext = createContext<AuthContextType | null>(null);
// Vite template react-ts ที่สร้างในบท 1 ใช้ React 19 อยู่แล้ว — ใช้ syntax นี้ได้เลย
// ถ้าไม่แน่ใจ: ดู package.json ว่า "react": "19.x" หรือสูงกว่า
// (@types/react@19 = package type definitions สำหรับ React 19 — ต้อง install ด้วย npm install @types/react@19)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// useMemo ป้องกัน value object ใหม่ทุก render → ทุก consumer ไม่ re-render โดยไม่จำเป็น
const value: AuthContextType = useMemo(() => ({
user,
login: setUser,
logout: () => setUser(null),
}), [user]);
// React 19 (ต้อง react@19 + @types/react@19): ใช้ <Context> ตรง ๆ — รูปแบบใหม่
return <AuthContext value={value}>{children}</AuthContext>;
// React 18 (และยังใช้ได้ใน R19 เพื่อ backwards-compat): ใช้ <Context.Provider>
// return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}📖 เจอ tutorial ไหนใช้
.Providerแล้วงงว่าตัวไหนถูก? — ทั้งสองใช้ได้ใน React 19 ตัวอย่างเก่า ๆ จะใช้.Provider(เพราะของเก่ามี) ในงานใหม่ปี 2026 เขียนแบบไหนก็ทำงานเหมือนกัน
ใช้:
tsx
// main.tsx
<AuthProvider>
<App />
</AuthProvider>
// ใน component ที่ไหนก็ได้
function UserMenu() {
const { user, logout } = useAuth();
return (
<div>
{user?.name}
<button onClick={logout}>Logout</button>
</div>
);
}ไม่ต้อง pass user ผ่าน prop chain
⚠️ context ทำให้ทุก consumer re-render
ถ้า value object ใหม่ทุก render → ทุก consumer re-render
แก้: split context หรือใช้ state management library (Zustand, Jotai)
เมื่อไหร่ใช้ context
| สถานการณ์ | ใช้อะไร |
|---|---|
| Theme, locale, current user | Context |
| Form state ใน 1 page | useState + prop |
| Server data (cache, refetch) | TanStack Query (รองรับ stale-while-revalidate = โชว์ข้อมูลเก่าไปก่อนขณะรอ fetch ใหม่เบื้องหลัง) |
| Complex global state | Zustand / Redux |
8. useReducer — state ซับซ้อน
ถ้า state มีหลาย field ที่ update ต่าง ๆ — useReducer อ่านง่ายกว่า useState หลายตัว
นึกภาพ reducer เป็นเสมียนโต๊ะ — คุณส่งโน้ต (action) บอกว่า "increment" หรือ "reset" ไปให้ เสมียนรับ state ปัจจุบัน + โน้ตนั้น แล้วคืน state ใหม่ให้คุณ คุณไม่ได้แก้ค่าเอง แค่ส่งคำสั่งไป ทำไมไม่ setCount ตรง ๆ? — ถ้ามี 4 ปุ่ม แต่ละตัว set field ต่างกัน logic กระจายอยู่ใน event handler ทั่วไฟล์ หา bug ยาก reducer รวม logic ทั้งหมดไว้ที่เดียว แก้ที่เดียวแก้ได้ทุกปุ่ม
ตัวอย่างเล็กสุดก่อน — state เดียว, action เดียว:
tsx
// reducer function รับ state ปัจจุบัน + action แล้วคืน state ใหม่
function simpleReducer(state: { count: number }, action: { type: 'increment' }) {
return { count: state.count + 1 };
}
function SimpleCounter() {
const [state, dispatch] = useReducer(simpleReducer, { count: 0 });
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
</div>
);
}ตอนนี้ขยายเป็น 4 action — โครงสร้างเดิม แค่เพิ่ม case ใน switch:
tsx
type State = { count: number; step: number };
// type Action เป็น union type (จากบท 0 §4.4) — หมายความว่า action เป็นได้หนึ่งในรูปแบบที่ระบุ โดยแยกด้วย field `type`
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'setStep'; step: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment': return { ...state, count: state.count + state.step };
case 'decrement': return { ...state, count: state.count - state.step };
case 'reset': return { ...state, count: 0 };
case 'setStep': return { ...state, step: action.step };
default: {
// ถ้าเพิ่ม action type ใหม่ในอนาคตแต่ลืมเพิ่ม case — บรรทัดนี้จะขึ้น TS error ทันที (compile-time)
// เพราะ action ที่เหลือควรมี type เป็น never (ไม่มีเคสไหนหลงเหลือ) ถ้ายังมีก็แปลว่าลืม handle
const exhaustiveCheck: never = action;
return state;
}
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<p>{state.count} (step: {state.step})</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', step: Number(e.target.value) })}
/>
</div>
);
}8.5 React 19 Hooks ใหม่ — ต้องรู้
🔴🔴 โซนขั้นสูงสุดของบท — ข้ามได้แน่นอน ไม่กระทบบท 3-10 (ข้ามไปอ่าน §9 ต่อได้เลย กลับมาอ่านทีหลังหลังผ่านบท 11)
ส่วนนี้แตะ:
- Server Action — function ที่รันบน server แต่ form เรียกได้เหมือนเขียน JS ปกติ (ต้องใช้ Next.js — บท 11)
- Suspense — กลไกของ React สำหรับ "รอ async" (ยังไม่ต้องรู้ละเอียด — มันคือ component ที่โชว์ loading ระหว่างรอ async, รายละเอียดบท 9)
throw promise— เทคนิคที่ component "โยน" promise ที่ยังไม่เสร็จออกไปให้ Suspense จับอ่านได้ตอนนี้: §8.5.1 (useId), §8.5.5 (React Compiler) — 2 อันนี้ standalone ข้ามไปก่อน: §8.5.2-8.5.4 (use/useOptimistic/useActionState) — กลับมาหลังผ่านบท 11
ถ้าฝืนอ่าน — โอเค แต่อย่ารู้สึกผิดที่ไม่เข้าใจครบ
React 19 (ปี 2024-2026) เพิ่ม hook ใหม่ที่ลด boilerplate (โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้ง น่าเบื่อแต่จำเป็น) ลงเยอะ — ส่วนใหญ่จับคู่กับ Server Action (function ที่รันบน server แต่ client เรียกได้เหมือนเขียน JS ปกติ — ต้องใช้ Next.js บท 11) (ไว้เจอบท 11) แต่ใช้ใน client-only ได้ด้วย
8.5.1 useId — สร้าง id เฉพาะตัว (สำหรับ a11y — accessibility = การเข้าถึงได้ของผู้ใช้ทุกกลุ่ม รวมถึงผู้พิการ)
📖 useId เพิ่มมาตั้งแต่ React 18 (ไม่ใช่ React 19) — ใช้ได้ใน React 18+
ก่อนมี useId — สร้าง id ของ form input + label สับสน (โดยเฉพาะเมื่อ render หลาย instance)
tsx
function EmailField() {
const id = useId(); // ⭐ id ที่ unique + คงที่ระหว่าง render
return (
<>
<label htmlFor={id}>Email</label>
<input id={id} type="email" />
</>
);
}ดี: id เดียวกันระหว่าง server + client render → ไม่เกิด hydration mismatch (ค่าจาก server กับ client ไม่ตรงกัน)
📖 hydration = การที่ React "เติมชีวิต" ใส่ HTML ที่ server ส่งมา ทำให้ปุ่ม/event กดได้ — ละเอียดบท 11/14
⚠️ อย่าใช้เป็น key ใน list — ใช้สำหรับ a11y attribute เท่านั้น
8.5.2 React.use() — เรียก context/promise แบบ flexible
React.use(Context) ทำงานเหมือน useContext แต่ เรียกหลัง conditional (if) หรือหลัง early return ได้ (เป็นข้อยกเว้นของกฎ hook — แต่ยังเรียกใน loop ไม่ได้ ดู react.dev)
⚠️ ข้อยกเว้นมีแค่เรื่อง if/early-return เท่านั้น —
use()ยังต้องถูกเรียก ระหว่าง render ของ component หรือ hook อยู่ดี ห้ามเรียกใน event handler, ในuseEffect, หรือใน callback อื่น ๆ (จะ throw error ทันที)
tsx
import { use } from 'react'; // ต้อง import use จาก react
// สมมุติว่ามี ThemeContext (สร้างด้วย createContext) ใน app แล้ว
function Component({ show }: { show: boolean }) {
if (!show) return null;
const theme = use(ThemeContext); // ⭐ เรียกหลัง early return ได้!
return <div className={theme}>...</div>;
}use(promise) ใช้กับ Suspense ซึ่งทำงานอย่างนี้: เมื่อ component เรียก use(promise) และ promise ยังไม่เสร็จ — React จะโยนป้าย "ยังไม่พร้อม" ออกไป Suspense ที่ครอบอยู่ข้างนอกรับป้ายนั้นแล้วโชว์ fallback (เช่น Loading...) แทน พอ promise resolve เสร็จ Suspense ถึงวาด component จริงออกมา
แต่มี กฎสำคัญที่พลาดไม่ได้: ห้ามสร้าง promise ใน body ของ Client Component ตรง ๆ — เพราะเกิด loop อนันต์:
- render → สร้าง promise ใหม่
use()โยนป้าย "ยังไม่พร้อม" → Suspense จับ → render ใหม่- render → สร้าง promise ใหม่อีกครั้ง (object ใหม่ ไม่ใช่อันเดิม)
- วนซ้ำ → หน้าค้างตลอด
แก้ด้วยการสร้าง promise นอก component ที่ระดับ module — สร้างครั้งเดียวตอน import file ตลอดชีวิต app จะใช้ object เดิมอยู่เสมอ
tsx
// ❌ ผิด — สร้าง promise ใน render body ของ Client Component
// (fetchUser คือ function ที่เรียก API — ตัวอย่างนี้เพื่ออ่านทำความเข้าใจเท่านั้น ไม่ต้องรันเอง)
function App() {
const promise = fetchUser(1); // ใหม่ทุก render → infinite suspend loop
return (
<Suspense fallback={<Spinner />}>
<UserCard userPromise={promise} />
</Suspense>
);
}tsx
// ✅ ถูก — สร้าง promise ครั้งเดียว นอก render (module-level cache)
// — promise นี้ stable ระหว่าง render
import { use, Suspense } from 'react';
const userPromise = fetchUser(1); // ⭐ สร้างครั้งเดียวตอน import module
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // ⭐ throw promise → Suspense จับ
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserCard userPromise={userPromise} />
</Suspense>
);
}📖 "throw promise" = เทคนิคที่ component โยน promise ที่ยังไม่เสร็จออกไป ให้ Suspense ที่อยู่ข้างนอกจับไว้แล้วโชว์ fallback แทน (ละเอียดบท 9)
ในงานจริง — promise จะมาจาก:
- Server Component (บท 11) ที่ส่ง promise มาเป็น prop
- TanStack Query (บท 4) ที่จัดการ cache ให้
- Route loader เช่น React Router/Next.js
module-level cache ในตัวอย่างข้างบนเป็นรูปแบบ "พื้นฐานสุด" สำหรับการเรียนรู้ — เห็นใน production code น้อย
8.5.3 useOptimistic — UI ตอบสนองทันที
💡 ถ้ายังไม่เข้าใจคำศัพท์ในส่วนนี้ — ข้ามได้เลย ไม่กระทบความเข้าใจบทอื่น กลับมาอ่านหลังผ่านบท 11
ใช้ pattern: optimistic update (อัปเดตแบบมองโลกในแง่ดี — โชว์ผลลัพธ์บนจอทันทีโดยยังไม่รอ server ตอบ ถ้า server fail ค่อยย้อนกลับ) โดยไม่ต้องเขียน rollback logic (ตรรกะย้อนสถานะ) เอง
tsx
type Message = { id: string; text: string; pending?: boolean };
type Props = {
messages: Message[];
send: (text: string) => Promise<void>;
};
function MessageList({ messages, send }: Props) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(current, newMsg: string) => [
...current,
{ id: 'temp', text: newMsg, pending: true },
]
);
async function action(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic(text); // UI update ทันที
await send(text); // ถ้า fail → React auto-rollback
}
return (
<>
{optimistic.map(m => (
<p key={m.id} style={{ opacity: m.pending ? 0.5 : 1 }}>{m.text}</p>
))}
{/* ⚠️ <form action={asyncFn}> เป็น React 19 feature ที่ทำงานสมบูรณ์ใน Next.js หรือ framework ที่รองรับ Server Actions
ถ้าใช้ใน Vite CSR ต้อง handle ผ่าน onSubmit event แทน */}
<form action={action}>
<input name="text" />
</form>
</>
);
}→ ดูตัวอย่างละเอียดในบท 11 §20.1
8.5.4 useActionState + useFormStatus
ลด boilerplate ของ form ที่ submit + error handling — รายละเอียดในบท 11 §20.1
8.5.5 ⭐ React Compiler — ไม่ต้องเขียน useMemo/useCallback เอง
ปี 2026 React Compiler (Babel/SWC plugin) auto-insert memoization ให้ component ที่เขียนตาม Rules of React — ถ้า component มี side effect ใน render หรือ mutation Compiler จะ skip optimization นั้น (ไม่ใช่ auto-insert ให้ทุก component เสมอ)
tsx
// คุณเขียน:
function Component({ items }) {
const filtered = items.filter(x => x.active); // expensive
const handleClick = (id) => doStuff(id);
return <Child items={filtered} onClick={handleClick} />;
}
// Compiler optimize ภายใน → เทียบเท่ากับ useMemo + useCallback อัตโนมัติเงื่อนไข: ต้องเขียนตาม "Rules of React":
- Pure component (component บริสุทธิ์ — input เดียวกันให้ output เดียวกันเสมอ ไม่มี side effect ใน render)
- ไม่ mutate props/state
- เรียก hook ตามลำดับ
enable ใน Vite (ปี 2026 React Compiler ยังเป็น opt-in ต้อง install เพิ่ม):
bash
npm install -D babel-plugin-react-compilerts
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react({
babel: { plugins: [['babel-plugin-react-compiler']] }
})],
});→ ปี 2026 — ไม่ต้องเขียน useMemo/useCallback ในส่วนใหญ่ของ code อีก (ยังต้องในบาง edge case)
8.5.6 useTransition — แยก urgent vs non-urgent update
ขยายศัพท์: urgent (เร่งด่วน — เช่นกดพิมพ์ต้องเห็นตัวอักษรทันที), non-urgent (ไม่เร่งด่วน — รอได้สักหน่อย), input lag (กดพิมพ์แล้ว UI ตามไม่ทัน), interrupt = หยุดกลางคันได้
บางครั้ง state update ทำให้ tree ใหญ่ re-render ช้า (filter list 10,000 รายการ) แล้วทำให้ input lag — กดพิมพ์แล้วช้า useTransition บอก React ว่า "update นี้ไม่เร่งด่วน — interrupt ได้":
tsx
import { useState, useTransition, type ChangeEvent } from 'react';
type Item = { name: string };
type Props = { items: Item[] };
function SearchList({ items }: Props) {
const [inputValue, setInputValue] = useState(''); // urgent — ควบคุม input โดยตรง
const [query, setQuery] = useState(''); // non-urgent — ใช้ filter
const [isPending, startTransition] = useTransition();
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setInputValue(e.target.value); // urgent — input ตามทันที
startTransition(() => {
setQuery(e.target.value); // non-urgent — filter ตามทีหลัง ไม่ block input
});
}
const filtered = items.filter(i => i.name.includes(query));
return (
<>
<input
value={inputValue} // controlled input: value + onChange
onChange={handleChange}
/>
{isPending && <Spinner />}
<List items={filtered} />
</>
);
}isPending=trueระหว่าง transition กำลังทำงาน- React จะ interrupt transition update ถ้ามี urgent update มาก่อน
- ระหว่าง
isPending=trueผู้ใช้จะยังเห็นผล filter เก่า (จากqueryค่าเก่า) พร้อม spinner ทับอยู่ — จนกว่า transition ของqueryใหม่จะเสร็จ ถึงจะเห็นผล filter ที่อัปเดตแล้ว
📖 ถ้าอยากให้ input โชว์ค่า "ทันที" แต่ filter "ตามทีหลัง" — ใช้
useDeferredValue(§8.5.7) แทน เป็นรูปแบบที่ idiomatic (ตรงตามแนวที่ React docs แนะนำ — เขียนแบบนี้แล้วโค้ดจะดูเป็น React style และ maintenance ง่ายกว่า) มากกว่าการแยก state เป็น 2 ตัว
8.5.7 useDeferredValue — ใช้ค่าเก่าไปก่อน ระหว่างรอค่าใหม่
คล้าย useTransition แต่ใช้กับ "ค่า" ไม่ใช่ "function update" — ดีเมื่อรับ prop จาก parent ที่เปลี่ยนเร็ว:
tsx
function ProductList({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const results = filterProducts(deferredQuery); // expensive
return (
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{results.map(p => <Item key={p.id} {...p} />)}
</div>
);
}useTransition— เราคุม update เอง (มีstartTransition)useDeferredValue— เรารับค่าจากข้างนอก แล้วบอก React ว่า "ค่านี้ตามไม่ทันได้"
8.5.8 useLayoutEffect — sync DOM ก่อน paint
useEffect รันหลัง browser paint (browser paint = ขั้นตอนที่ browser วาดผล pixel ออกจอ หลัง layout เสร็จ) → ถ้า effect แก้ DOM (วัด element, set scroll position) จะเห็น "กระตุก" หนึ่ง frame useLayoutEffect รันก่อน paint → ไม่กระตุก แต่ block paint จนเสร็จ:
tsx
type Props = {
targetRef: React.RefObject<HTMLElement>;
children: React.ReactNode;
};
function Tooltip({ targetRef, children }: Props) {
const [pos, setPos] = useState({ top: 0, left: 0 });
useLayoutEffect(() => {
// วัด target แล้ว set position ก่อน paint → ไม่เห็นกระตุก
// targetRef.current ไม่ null เพราะ useLayoutEffect รันหลัง DOM mount (จึงใช้ ! non-null assertion ได้ปลอดภัย)
const rect = targetRef.current!.getBoundingClientRect();
setPos({ top: rect.bottom, left: rect.left });
}, [targetRef]);
return <div style={pos}>{children}</div>;
}กฎเลือก:
- 99% case →
useEffect - ต้องวัด DOM แล้ว set state ที่ส่งผลต่อ layout →
useLayoutEffect - SSR (Server-Side Rendering — render บน server) →
useLayoutEffectwarn (เพราะ server ไม่มี DOM) → ใช้useEffectแทน ในกรณีส่วนใหญ่ —useLayoutEffectควรใช้เฉพาะกับ client-only component เท่านั้น
💡 ถ้ายังไม่รู้จัก SSR — ข้ามบรรทัดนี้ได้ ส่วนใหญ่ใช้ Vite (CSR) ซึ่งไม่มีปัญหานี้ จะเจอจริงในบท 11
8.5.9 useSyncExternalStore — bind external store เข้า React
ใช้สำหรับเขียน custom store (Zustand ใช้ภายใน) — แค่รู้ว่ามีไว้ ถ้าใช้ Zustand/Jotai ไม่ต้องเรียกเอง
📖 99% ของ application code ไม่ต้องเรียกตัวนี้ — library state management เรียกให้แล้ว — ข้ามได้
tsx
function useWindowWidth() {
return useSyncExternalStore(
(cb) => { window.addEventListener('resize', cb); return () => window.removeEventListener('resize', cb); },
() => window.innerWidth,
() => 0 // server snapshot (SSR)
);
}9. Custom Hooks
Custom Hook = เอา logic ที่ใช้ซ้ำได้ข้าม component หลาย ๆ ตัวมาแยกไว้ในที่เดียว
ก่อนมี custom hook — สมมุติมี 2 component ที่ต้องอ่านเขียน localStorage:
tsx
// ThemeSettings.tsx
function ThemeSettings() {
const [theme, setTheme] = useState(() => localStorage.getItem('theme') ?? 'light');
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
return <select value={theme} onChange={e => setTheme(e.target.value)}>...</select>;
}
// UserSettings.tsx — code เดิมทุกอย่าง แค่เปลี่ยน key
function UserSettings() {
const [lang, setLang] = useState(() => localStorage.getItem('lang') ?? 'th');
useEffect(() => {
localStorage.setItem('lang', lang);
}, [lang]);
return <select value={lang} onChange={e => setLang(e.target.value)}>...</select>;
}ถ้ามี 5 หน้าที่ต้องจำค่าไว้ใน localStorage ก็ต้อง copy code ชุดนี้ 5 ครั้ง และถ้า logic เปลี่ยน (เช่นเพิ่ม error handling) ต้องแก้ 5 ที่พร้อมกัน custom hook คือการตัด code ซ้ำนั้นออกมาตั้งชื่อ แล้วทุก component เรียกใช้ได้เลย:
tsx
const [theme, setTheme] = useLocalStorage('theme', 'light'); // ✅ 1 บรรทัดแทน 6 บรรทัด
const [lang, setLang] = useLocalStorage('lang', 'th');ตัวอย่าง: useLocalStorage
📖 hook นี้ทำงานเฉพาะ CSR (client-side only — รันใน browser) ถ้าใช้กับ Next.js หรือ SSR framework ต้อง guard ด้วย
typeof window !== 'undefined'หรือใช้ library เช่นuse-local-storage-stateที่จัดการ SSR ให้
tsx
function useLocalStorage<T>(key: string, initial: T) {
// () => { ... } ใน useState คือ lazy initializer — รันครั้งเดียวตอน mount ไม่รันซ้ำทุก render
// ใช้เมื่อการคำนวณค่าตั้งต้นแพง เช่น อ่านจาก localStorage
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const; // `as const` บอก TypeScript ว่า return เป็น tuple 2 element ทำให้ const [v, set] = useLocalStorage(...) มี type ถูกต้อง (รายละเอียดเพิ่มเติมบท 13)
}
// ใช้
function App() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
// ...
}ตัวอย่าง: useDebounce
debounce = เทคนิคที่ทำให้ code รันหลังจากผู้ใช้หยุดกระทำ (เช่น หยุดพิมพ์) เป็นเวลาหนึ่ง — ป้องกันการ search ทุกครั้งที่กดแป้นพิมพ์
tsx
function useDebounce<T>(value: T, delay: number = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// ใช้
function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
// search API
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}ตัวอย่าง: useFetch
⚠️ ตัวอย่างเพื่อการเรียนรู้เท่านั้น — โชว์ว่า
useState+useEffectประกอบกันเป็น custom hook ยังไง อย่าใช้ตัวนี้ใน production เพราะขาด: cache, dedup, retry, stale-while-revalidate, query invalidation ฯลฯ ในงานจริงใช้ TanStack Query (บท 4) ที่จัดการเรื่องนี้ครบ
tsx
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true); // เรียกก่อน — ถ้า unmount ขณะ fetch ยังค้างอยู่ state loading จะ reset ตอน component ถูกสร้างใหม่
setError(null); // (simplified example — production ควรใช้ TanStack Query แทน)
fetch(url)
.then(r => {
// r.ok = true ถ้า status 200-299 (สำเร็จ), false ถ้า 4xx/5xx (error)
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => { if (!cancelled) setData(data); })
.catch(err => { if (!cancelled) setError(err); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// ใช้
function UserList() {
const { data, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}กฎ custom hook
- ต้องขึ้นต้นด้วย
use(useFetch,useAuth,useDebounce) - เรียก hook อื่นภายในได้
- ตามกฎของ hook ทุกข้อ
- return อะไรก็ได้ (value / array / object)
10. ตัวอย่างใหญ่ — Stopwatch + history
📝 ตัวอย่างนี้เขียน
useMemo+useCallbackไว้ เพื่อให้เห็นว่าแต่ละตัวทำงานยังไง — ในงานจริงที่เปิด React Compiler แล้ว Compiler จะเพิ่มให้อัตโนมัติ ไม่ต้องเขียนเอง
ตัวอย่างรวบยอดของบท hooks — Stopwatch ที่ใช้ useState (เวลา/สถานะ), useRef (เก็บ interval id), useEffect (จัดการ timer + cleanup) ทำงานร่วมกัน เห็นได้ว่าแต่ละ hook มีหน้าที่ชัดเจนและประกอบกันเป็น component ที่มี side effect จริง:
tsx
function Stopwatch() {
const [time, setTime] = useState(0);
const [running, setRunning] = useState(false);
const [history, setHistory] = useState<number[]>([]);
const intervalRef = useRef<number | null>(null);
useEffect(() => {
if (running) {
intervalRef.current = setInterval(() => {
setTime(t => t + 0.1);
}, 100);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [running]);
const start = () => setRunning(true);
const stop = () => setRunning(false);
const reset = () => { setRunning(false); setTime(0); };
// useCallback: ถ้า time ไม่เปลี่ยน function `lap` เป็นตัวเดิม
// → ส่งลง child ที่ memo แล้วไม่ทำให้ child re-render เกิน
const lap = useCallback(() => {
setHistory(prev => [...prev, time]);
}, [time]);
const formatted = time.toFixed(1); // ไม่ต้อง useMemo — simple string format ไม่คุ้มค่า overhead (ดู §3.2)
return (
<div>
<h1>{formatted}s</h1>
<div>
<button onClick={running ? stop : start}>
{running ? 'Stop' : 'Start'}
</button>
<button onClick={lap} disabled={!running}>Lap</button>
<button onClick={reset}>Reset</button>
</div>
<ul>
{history.map((t, i) => <li key={i}>Lap {i + 1}: {t.toFixed(1)}s</li>)}
{/* key={i} ใช้ index ได้ — lap history ไม่เคยเปลี่ยนลำดับ แค่เพิ่มท้าย */}
</ul>
</div>
);
}ใช้ครบ: useState, useEffect, useRef, useCallback, useMemo
11. Checkpoint
📝 หมายเหตุ: ตั้งแต่บทนี้เป็นต้นไป checkpoint ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน (ต่างจากบท 0–1 ที่มีเฉลยให้) — ถ้าติดจริง ๆ ให้ย้อนกลับไปอ่านตัวอย่างโค้ดในบท แล้วลองดัดแปลง การได้ลองผิดลองถูกเองคือส่วนที่ทำให้จำได้จริง
🛠️ Checkpoint 2.1 — useEffect
ทำหน้า user list ที่ fetch จาก https://jsonplaceholder.typicode.com/users แสดง name + email + จัดการ loading / error
💡 ถ้าไม่มี internet ใช้ mock data แทน:
const mockUsers = [{id:1, name:"Alice", email:"a@b.com"}]แล้วลองจำลอง loading state ด้วยsetTimeout
🛠️ Checkpoint 2.2 — useDebounce
สร้าง search box ที่ debounce 300ms — แสดง "Searching for: <query>" หลัง user หยุดพิมพ์
🛠️ Checkpoint 2.3 — useContext
สร้าง ThemeContext (light/dark) — provider ใน App, ปุ่ม toggle ใน header, component ที่ไหนก็เปลี่ยนสีตาม theme
🛠️ Checkpoint 2.4 — Custom hook
ทำ useLocalStorage<T> แล้วใช้ใน todo list (จากบทที่แล้ว) ให้จำ todos
12. สรุปบท
✅ กฎ hook: top level + เฉพาะใน component/custom hook ✅ useEffect(fn, deps) — side effect, return cleanup function ✅ Strict Mode run effect 2 รอบใน dev → เขียนให้ทน ✅ Race condition ใน fetch → flag หรือ AbortController ✅ useMemo cache ค่าจาก compute, useCallback cache function ✅ React.memo skip re-render ถ้า prop เท่าเดิม ✅ useRef เก็บค่าโดยไม่ trigger render — เข้าถึง DOM, timer ✅ useContext share state ใน subtree (theme, auth) ✅ useReducer แทน useState หลายตัวเมื่อ state ซับซ้อน ✅ Custom hook ต้องขึ้นต้นด้วย use ✅ React 19 ใหม่: useId (a11y), use() (context/promise flexible), useOptimistic (UI ทันที), useActionState + useFormStatus (form), useSyncExternalStore (external store) ✅ React Compiler — auto memoization → ไม่ต้องเขียน useMemo/useCallback ส่วนใหญ่