Skip to content

บทที่ 2 — Hooks เชิงลึก (useEffect, useMemo, useCallback, useRef, useContext, custom hooks)

รู้จัก hook ตัวอื่น ๆ ของ React (นอกจาก useState ที่บทแล้วเรียนไป) — จัดการ side effect, optimize re-render, แชร์ state ข้าม component, และสร้าง hook ของตัวเอง

← บทที่ 1 | สารบัญ | บทที่ 3: Form →

🟢 ระดับ: มือใหม่ — แต่ §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 อื่น ไม่ใช่ใน useEffect

2.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

stateref
เปลี่ยน trigger render?
Sync หรือ asyncasync batchsync immediate
ใช้เก็บอะไรdata ที่แสดงใน UIDOM, 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 userContext
Form state ใน 1 pageuseState + prop
Server data (cache, refetch)TanStack Query (รองรับ stale-while-revalidate = โชว์ข้อมูลเก่าไปก่อนขณะรอ fetch ใหม่เบื้องหลัง)
Complex global stateZustand / 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-compiler
ts
// 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) → useLayoutEffect warn (เพราะ 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 ต้องขึ้นต้นด้วย useReact 19 ใหม่: useId (a11y), use() (context/promise flexible), useOptimistic (UI ทันที), useActionState + useFormStatus (form), useSyncExternalStore (external store) ✅ React Compiler — auto memoization → ไม่ต้องเขียน useMemo/useCallback ส่วนใหญ่

→ ไปบทที่ 3: Form + Validation