Rules & Custom Hooks
Rules of Hooks มีแค่สองข้อ:
- เรียก hook ที่ top level เท่านั้น — ห้ามเรียกใน condition, loop หรือ nested function
- เรียก hook จาก React function เท่านั้น — component หรือ hook อื่น ไม่ใช่ function ธรรมดา
function Profile({ userId }) { const [user, setUser] = useState(null); // ✅ top level
if (!userId) { const [error, setError] = useState(null); // ❌ conditional — breaks the rules }
for (const id of ids) { useEffect(() => { /* ... */ }); // ❌ in a loop — breaks the rules }}ทำไมกฎข้อแรกถึงมี
หัวข้อที่มีชื่อว่า “ทำไมกฎข้อแรกถึงมี”จำจาก intro ของโมดูล: React จับคู่ hook แต่ละตัวกับ memory slot ตามลำดับการเรียก ไม่ใช่ตามชื่อ useState ตัวแรกคือ slot 1, ตัวที่สองคือ slot 2 ทุก render
flowchart TB r1["Render 1: useState, useState, useEffect slots 1, 2, 3"] --> ok["consistent"] r2["Render 2: (condition false) useState, useEffect slots 1, 2"] --> bad["useEffect now reads slot 2 — the state of the wrong hook"]
ถ้าคุณเรียก hook แบบมีเงื่อนไข พอถึง render ที่เงื่อนไขต่างออกไป จำนวนและลำดับ ของการเรียก hook จะเปลี่ยน — และ hook ทุกตัวหลังตัวที่มีเงื่อนไขจะอ่าน slot ผิด state รั่วข้าม hook วุ่นวายตามมา การเรียก hook แบบไม่มีเงื่อนไข ในลำดับเดิมทุก render คือสิ่งที่ทำให้การ map slot คงที่ นั่นคือเหตุผลทั้งหมดของกฎ
บังคับใช้อัตโนมัติด้วย lint rule ทางการ (eslint-plugin-react-hooks) — plugin นี้จับ hook แบบมีเงื่อนไขและ effect dependency ที่ขาดก่อน ship
พิสูจน์: error จริง ไม่ใช่แค่ lint warning
หัวข้อที่มีชื่อว่า “พิสูจน์: error จริง ไม่ใช่แค่ lint warning”Rules of Hooks ไม่ได้เป็นแค่ธรรมเนียมของ lint — การทำผิดกฎตอน runtime ในแบบที่ static analysis ของ ESLint จับไม่ได้ (เช่นเงื่อนไขที่ บางครั้ง เท่านั้นที่ทำให้จำนวน hook ลดลง) จะ throw error จริงของ React ไม่ใช่บั๊กเงียบ ๆ:
import { useState } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';
afterEach(cleanup);
function Buggy({ skip }: { skip: boolean }) { const [a] = useState('a'); if (!skip) { // 🔴 Conditional hook call — only runs on some renders. // eslint-disable-next-line react-hooks/rules-of-hooks useState('b'); } return <span>{a}</span>;}
test('calling a hook conditionally on re-render throws React\'s real "Rendered fewer hooks" error', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { rerender } = render(<Buggy skip={false} />); // useState('a'), useState('b') expect(() => { rerender(<Buggy skip />); // this render only calls useState('a') — count changed }).toThrowError(/Rendered fewer hooks than expected/); spy.mockRestore();});✓ calling a hook conditionally on re-render throws React's real "Rendered fewer hooks" error
Test Files 1 passed (1) Tests 1 passed (1)ข้อความจริงที่ React throw จับได้ในโพรบ:
Rendered fewer hooks than expected. This may be caused by an accidental early return statement.ประโยคที่สองคือเบาะแสจริง: สาเหตุที่พบบ่อยกว่าในโค้ดจริงไม่ใช่ if ที่เขียนล้อม hook เอง — แต่เป็น return ก่อนเวลาที่วางไว้ ก่อน hook ที่เคยรันทุก render
Custom hooks: การแชร์ stateful logic
หัวข้อที่มีชื่อว่า “Custom hooks: การแชร์ stateful logic”custom hook คือ function ที่ชื่อขึ้นต้นด้วย use และเรียก hook อื่น เท่านั้นเอง — ไม่มี API พิเศษ แต่ให้คุณแยกและ reuse stateful logic (ไม่ใช่ UI) ข้าม component
// A custom hook: reusable logic, its own state, calls built-in hooks.function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { const on = () => setIsOnline(true); const off = () => setIsOnline(false); window.addEventListener('online', on); window.addEventListener('offline', off); return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); }; }, []); return isOnline;}
// Any component can now use it:function StatusBar() { const isOnline = useOnlineStatus(); return <span>{isOnline ? '✅ Online' : '❌ Offline'}</span>;}สิ่งสำคัญที่ต้องเข้าใจ: custom hook แชร์ logic ไม่ใช่ state ทุก component ที่เรียก useOnlineStatus() ได้ state ของตัวเองอิสระ — hook เป็นสูตรที่รันใหม่ต่อ component ไม่ใช่ store ที่แชร์กัน (สำหรับ state ที่แชร์ข้าม component ให้ lift state up หรือใช้ context/external store — โมดูลถัดไป)
เมื่อไรควรแยก custom hook: เมื่อสอง component ต้องการ stateful behavior เดียวกัน หรือเมื่อ logic ของ component ซับซ้อนพอที่การตั้งชื่อ (useForm, useDebouncedValue, useFetch) ทำให้ component ชัดขึ้น
custom hook ที่เขียนดี return function แบบ stable
หัวข้อที่มีชื่อว่า “custom hook ที่เขียนดี return function แบบ stable”custom hook ที่ส่ง callback กลับมา (สำหรับ memoized child หรือ effect dependency ที่อื่น) ควร return function reference เดียวกัน ข้าม re-render เมื่อไม่มีอะไรที่มันขึ้นกับเปลี่ยน — เหมือนกับที่คุณอยากได้จาก function ใดก็ตามที่เขียนมือ useCallback ข้างในตัว hook คือสิ่งที่ทำให้เป็นจริง:
import { useCallback, useState } from 'react';
function useCounter() { const [count, setCount] = useState(0); const increment = useCallback(() => setCount((c) => c + 1), []); return { count, increment };}
export default useCounter;import { useCallback, useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function useCounter() { const [count, setCount] = useState(0); const increment = useCallback(() => setCount((c) => c + 1), []); return { count, increment };}
test('a well-written custom hook returns the same function reference across re-renders', () => { const seen: (() => void)[] = []; function Probe({ unrelated }: { unrelated: number }) { const { increment } = useCounter(); seen.push(increment); return <span>{unrelated}</span>; } const { rerender } = render(<Probe unrelated={1} />); rerender(<Probe unrelated={2} />); // re-render for an unrelated reason expect(seen).toHaveLength(2); expect(seen[0]).toBe(seen[1]); // same reference, not a new one each render});
test('calling increment() twice via act reaches count 2', () => { function Probe() { const { count, increment } = useCounter(); return <button onClick={increment}>{count}</button>; } const { getByRole } = render(<Probe />); act(() => getByRole('button').click()); act(() => getByRole('button').click()); expect(getByRole('button').textContent).toBe('2');});✓ a well-written custom hook returns the same function reference across re-renders✓ calling increment() twice via act reaches count 2
Test Files 1 passed (1) Tests 2 passed (2)ถ้า useCounter เขียน const increment = () => setCount(c => c + 1); โดยไม่มี useCallback test แรกจะ fail: ทุก render จะส่ง function ใหม่กลับมา ซึ่งเป็นสิ่งที่ทำลาย React.memo child หรือทำให้ effect ที่ขึ้นกับมันรันใหม่โดยไม่จำเป็นพอดี (ดูบท memoization)
ข้อยกเว้น use ต่อ Rules of Hooks
หัวข้อที่มีชื่อว่า “ข้อยกเว้น use ต่อ Rules of Hooks”use (มาพร้อม Server Components และใช้ใน Client Components ได้ด้วยสำหรับแกะ promise ที่ส่งมาจาก server) ไม่ถูกผูก ด้วยกฎ “top level เท่านั้น” แบบที่ useState/useEffect/ฯลฯ ถูกผูก มันคือ built-in ตัวเดียวที่ถูกออกแบบมาให้เรียกใน condition, loop และหลัง early return ได้:
import { use } from 'react';
function Message({ show, promise }: { show: boolean; promise: Promise<string> }) { // ✅ `use` is allowed after a conditional early return — a real hook // (useState/useEffect/etc.) would violate the Rules of Hooks here. if (!show) return <p>hidden</p>; const text = use(promise); return <p>{text}</p>;}
export default Message;import { Suspense, use } from 'react';import { act, render, cleanup, screen, waitFor } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Message({ show, promise }: { show: boolean; promise: Promise<string> }) { if (!show) return <p>hidden</p>; const text = use(promise); return <p>{text}</p>;}
test('use() may be called after an early return, unlike a real Hook', async () => { const promise = Promise.resolve('loaded'); await act(async () => { render( <Suspense fallback={<p>loading</p>}> <Message show promise={promise} /> </Suspense>, ); }); await waitFor(() => expect(screen.getByText('loaded')).toBeTruthy());});✓ use() may be called after an early return, unlike a real Hook
Test Files 1 passed (1) Tests 1 passed (1)อย่าตีความว่านี่คือใบอนุญาตให้เรียก useState หลัง condition ด้วย — use เป็นข้อยกเว้นแบบพิเศษเฉพาะ (มันไม่ถูก track ด้วย per-render hook-slot list แบบ hook ปกติ) ไม่ใช่ช่องโหว่ในตัวกฎเอง กฎยังใช้กับ hook ทุกตัวที่ชื่อไม่ได้มาจาก API use() ของ React เอง
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Rules of Hooks ไม่ใช่แนวทาง style ที่วางทับ React — มันคือผลลัพธ์ตรงจากกลไก memory-slot ที่พูดไว้ใน intro ของโมดูล ถ้า hook ถูกจับคู่ด้วยชื่อ การเรียกแบบมีเงื่อนไขจะไม่มีปัญหาเลย เพราะมันถูกจับคู่ด้วย ตำแหน่งใน list ที่เรียงลำดับตายตัว รูปทรงของ list ต้องเหมือนกันทุก render เต็ม ๆ use ได้รับสิทธิ์ทำลายกฎนี้เพราะมันไม่ได้เป็นส่วนหนึ่งของ list นั้นเลย — มันอ่านตรงจาก cache ที่รู้จัก Suspense คีย์ด้วย promise/context ที่คุณส่งเข้าไป ซึ่งเป็นเหตุผลตรง ๆ ว่าทำไมมันไม่ต้องการความเสถียรของตำแหน่ง
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
บังคับใช้กฎด้วย ESLint (eslint-plugin-react-hooks) | จับการทำผิดกฎส่วนใหญ่ได้ก่อนรันจริง | บางการทำผิด (เงื่อนไขที่ บางครั้ง เท่านั้นที่ต่างออกไป) โผล่แค่ตอน runtime |
custom hook ที่ return ด้วย useCallback ห่อไว้ | ส่งให้ memoized child / effect deps ได้ปลอดภัย | code ในตัว hook เพิ่มขึ้นนิดหน่อย |
use() สำหรับแกะ data แบบมีเงื่อนไข | ข้าม fetch/suspend งานที่ไม่ต้องการได้ | ใช้ได้แค่กับ promise และ context — ไม่ใช่ escape hatch ทั่วไปสำหรับ hook อื่น |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ทำผิด Rules of Hooks แค่ lint warning ไม่ใช่บั๊กจริง” มันโยน runtime error จริงได้ — “Rendered fewer hooks than expected” — พิสูจน์แล้วด้านบน ไม่ใช่แค่ linter บ่น
- “early
returnก่อน hook ไม่เป็นไรตราบใดที่ hook เองไม่ได้อยู่ในif” อันตรายพอ ๆ กัน: early return ข้ามการเรียก hook ในบาง render ซึ่งเป็นปัญหา “จำนวนเปลี่ยน” แบบเดียวกับที่ condition ทำให้เกิด - “custom hook return reference แบบ stable ให้อัตโนมัติ” ไม่จริง — custom hook ก็แค่ function ถ้ามันส่ง closure ใหม่ทุกครั้งโดยไม่มี
useCallbackผู้เรียกจะได้ reference ใหม่ทุก render เหมือนเขียน inline เอง - “
useพิสูจน์ว่าเรียก hook ไหนก็ได้แบบมีเงื่อนไขถ้าระวังพอ”useเป็นข้อยกเว้นพิเศษที่ระบุไว้ชัดเจนและสร้างต่างจาก hook ปกติ — มันไม่ generalize ไปถึงuseState,useEffectหรือ hook ใดที่คุณหรือ library author เขียนเอง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ custom hook หรือ component ที่ generate มาใช้ hook ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- มี hook ตัวไหนถูกเรียกหลัง early
return, ในif/for/whileหรือใน nested callback — อะไรก็ตามที่ไม่ใช่ top level ของ component หรือ hook อื่นหรือเปล่า? - custom hook return function โดยไม่ห่อด้วย
useCallbackทั้งที่ function นั้นถูกส่งให้ memoized child หรือใช้เป็น effect dependency ที่อื่นหรือเปล่า? use()ถูกมองว่าปลอดภัยสำหรับเรียกแบบมีเงื่อนไข แต่ code จริงเรียกuseState/useEffect/ฯลฯ แบบมีเงื่อนไขด้วย โดยสมมติว่าข้อยกเว้นเดียวกันใช้ได้หรือเปล่า?- custom hook แชร์ state ข้าม component แบบเงียบ ๆ (เช่นผ่าน module-level variable) ทั้งที่ควรให้แต่ละผู้เรียกได้ state อิสระหรือเปล่า?
- จำนวน hook ของ component จะเปลี่ยนจริงระหว่าง render สำหรับ prop combination บางแบบที่ไม่ชัดเจนตอนอ่านหัว function หรือเปล่า?
🐛 หา bug ในโค้ดนี้
function Profile({ userId }) {const [user, setUser] = useState(null);
if (!userId) { return <p>No user selected</p>;}
useEffect(() => { fetchUser(userId).then(setUser);}, [userId]);
return <p>{user?.name}</p>;}เฉลย
useEffect อยู่ หลัง early return — เมื่อ userId เป็น falsy render นี้เรียกแค่ hook เดียว (useState); เมื่อเป็น truthy render นี้เรียกสองตัว (useState แล้ว useEffect) นั่นคือปัญหา “จำนวน hook เปลี่ยนข้าม render” ที่ Rules of Hooks มีไว้ป้องกันตรง ๆ และไม่ใช่แค่เรื่อง style: พิสูจน์แล้วก่อนหน้าในบทนี้ component ที่จำนวน hook ลดลงข้าม render จะ throw runtime error จริงของ React ว่า “Rendered fewer hooks than expected. This may be caused by an accidental early return statement.” — ซึ่งตรงกับสิ่งที่เกิดขึ้นตรงนี้เป๊ะ ทางแก้คือเรียก useEffect (และ hook อื่น) ก่อน conditional return ใด ๆ แล้วย้ายเช็ก “ควร fetch ไหม” เข้าไปในตัว effect แทน: useEffect(() => { if (!userId) return; fetchUser(userId).then(setUser); }, [userId]);