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 ต้องขึ้นต้นด้วย use ✅ React 19 ใหม่: useId (a11y), use() (context/promise flexible), useOptimistic (UI ทันที), useActionState + useFormStatus (form), useSyncExternalStore (external store) ✅ React Compiler — auto memoization → ไม่ต้องเขียน useMemo/useCallback ส่วนใหญ่

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