🧰 ของที่ใช้ตรวจในบทนี้
next 16.3.4 # จาก npx next --version
react / react-dom 19.2.8 # จาก package.json ของโปรเจกต์จริง
node v24.14.0 # จาก node --version
eslint 9 + eslint-config-next 16.3.4
ทุกก้อนโค้ดในบทนี้ถูกวางไว้จริงในโปรเจกต์ Next.js ตัวเป็น ๆ แล้ว
ผ่าน npx next build (ซึ่งรัน TypeScript ให้ด้วย) และ npx tsc --noEmit จริง
ตัวเลขบนหน้าจอกับข้อความ error ทุกบรรทัด ก็อปมาจากเบราว์เซอร์กับ terminal จริง
ไม่ได้เขียนจากความจำ
ปัญหาที่ทำให้ custom hook เกิดขึ้น
สมมติหน้าเว็บคุณมีสองที่ที่ต้องนับเลข — กล่องซ้ายกับกล่องขวา
ถ้ายังไม่รู้จัก custom hook คุณจะเขียนแบบนี้ คือก็อป useState กับฟังก์ชัน inc ไปวางซ้ำ
❌ ก็อป logic ไปวางซ้ำ
function BoxA() {
const [count, setCount] = useState(0);
const inc = () => setCount((c) => c + 1);
// …JSX…
}
function BoxB() {
const [count, setCount] = useState(0);
const inc = () => setCount((c) => c + 1);
// …JSX…
}
วันที่ลูกค้าขอ "ห้ามเกิน 10" คุณต้องไล่แก้ทุกที่ที่ก็อปไว้ · ลืมที่เดียว = บั๊กที่หาไม่เจอ
✅ ดึง logic ออกมาเป็น custom hook
// components/t77/useCounter.ts
import { useState } from "react";
export function useCounter(start = 0) {
const [count, setCount] = useState(start);
const inc = () => setCount((c) => c + 1);
return { count, inc };
}
// ใช้:
const { count, inc } = useCounter(0);
แก้กฎที่เดียว มีผลทุกที่ที่เรียก · และ component เหลือแต่เรื่อง "หน้าตา" ล้วน ๆ
นี่คือทั้งหมดที่สไลด์หน้า 61 พูด: "Enables to extract and reuse stateful component logic across multiple parts of application"
คำสำคัญคือ stateful — ถ้า logic ที่จะดึงออกมาไม่ได้ยุ่งกับ state หรือ side effect เลย
มันคือฟังก์ชันธรรมดา ไม่ใช่ hook (เดี๋ยวจะเห็นว่าเรื่องนี้สำคัญกว่าที่คิด ในกฎข้อ 2)
ร้านกาแฟมีเครื่องชง 2 เครื่อง บาริสต้า 2 คน
เมื่อก่อนแต่ละคนจำสูตรเอง วันที่เจ้าของสั่งลดน้ำตาล ต้องไปบอกทีละคน ลืมคนหนึ่ง = รสชาติเพี้ยน
วันนี้เจ้าของเขียนสูตรลงกระดาษแผ่นเดียว แปะไว้หลังเคาน์เตอร์ ทั้งสองคนอ่านแผ่นเดียวกัน
แก้สูตรที่แผ่นนั้นที่เดียว ทั้งร้านเปลี่ยนพร้อมกัน — กระดาษแผ่นนั้นคือ custom hook
ที่ไม่เหมือนกัน: อ่านสูตรแผ่นเดียวกัน แต่แก้วกาแฟไม่ใช่แก้วเดียวกัน
บาริสต้า A ชงไป 3 แก้ว ไม่ได้แปลว่า B ก็ชงไป 3 แก้ว
คนอ่านมักหยุดที่ "ใช้ของร่วมกัน" แล้วเผลอสรุปว่า "ค่าก็ร่วมกัน" — ผิด และนั่นคือกฎข้อ 4 ที่จะพิสูจน์กันด้านล่าง
- custom hook
- ฟังก์ชัน JavaScript ธรรมดา ๆ ที่ชื่อขึ้นต้นด้วย
useและข้างในเรียก hook ของ React — ไม่มี API พิเศษ ไม่มีimportอะไรเพิ่ม เขียนfunctionธรรมดาแล้วจบ · ถาม senior ได้ว่า “logic ก้อนนี้ควรดึงออกเป็น custom hook หรือเป็นฟังก์ชันธรรมดาดี” คำถามนี้จะได้คำตอบว่าก้อนนั้นมี state จริงไหม - stateful logic
- โค้ดที่จำอะไรบางอย่างข้ามการวาดหน้าจอ (มี
useState/useEffect/useRefอยู่ข้างใน) ต่างจากฟังก์ชันคำนวณล้วน ๆ ที่ใส่อะไรเข้าไปก็ได้ค่าเดิมออกมาเสมอ · พิมพ์บอก AI ได้ว่า “extract this stateful logic into a custom hook” จะได้ผลตรงกว่าบอกว่า “ทำให้โค้ดสั้นลงหน่อย”
กฎ 4 ข้อ ตามสไลด์หน้า 62
สไลด์หน้า 62 ขึ้นหัวว่า Core Rules แล้วให้มา 4 บรรทัด · ผมแปลไว้ในตารางนี้ พร้อมช่อง "ใครเป็นคนจับได้" ซึ่งสไลด์ไม่ได้บอก แต่เป็นช่องที่ทำให้คุณ debug เป็น
| กฎในสไลด์ | แปลเป็นภาษาคน | ใครจับได้ (ผมทดลองแล้ว) |
|---|---|---|
| Start with “use” name must begin with “use” |
ชื่อฟังก์ชันต้องขึ้นต้นด้วย use |
eslint ฟ้องตอนตรวจ · React ตอนรันไม่สนใจชื่อเลย |
| Call other hooks must call at least one built-in React Hook |
ข้างในต้องเรียก hook ของ React อย่างน้อยหนึ่งตัว | ไม่มีใครฟ้อง — แต่ถ้าไม่ทำ คุณจะโดน eslint ฟ้องผิด ๆ ทีหลัง |
| Top-level execution follow the regular rules of hooks |
ห้ามเรียกใน if / loop / หลัง return |
ทั้ง eslint และ React ตอนรัน — React โยน error จนหน้าพัง |
| Independent state it gets its own completely isolated state |
ทุกที่ที่เรียก ได้ state ของตัวเอง ไม่แชร์กัน | ไม่มีใครฟ้อง — เพราะมันไม่ใช่ error แต่เป็นวิธีที่มันทำงาน |
ข้อ 1 กับข้อ 3 มีเครื่องมือคอยตะโกนใส่คุณ ผิดปุ๊บรู้ปั๊บ
ข้อ 2 กับข้อ 4 ผิดแล้วเงียบ โปรแกรมรันได้ ไม่มีสีแดง แต่พฤติกรรมไม่ใช่อย่างที่คุณคิด
เวลาไล่บั๊กเรื่อง hook ให้เริ่มสงสัยสองข้อนี้ก่อนเสมอ
กฎ 1 · ชื่อต้องขึ้นต้นด้วย use — เพราะเครื่องมืออ่านแค่ชื่อ
คำถามที่ควรถามคือ "ถ้าไม่ตั้งชื่อว่า use แล้วมันพังตรงไหน?"
ผมเขียนฟังก์ชันเดียวกันเป๊ะ ๆ แต่ตั้งชื่อว่า getCounter แล้วรัน eslint ดู
// components/t77/getCounter.ts — เหมือน useCounter ทุกอย่าง ต่างแค่ชื่อ
import { useState } from "react";
export function getCounter(start: number = 0) {
const [count, setCount] = useState(start);
const inc = () => setCount((c) => c + 1);
return { count, inc };
}
$ npx eslint components/t77/getCounter.ts
คิดว่า eslint จะเงียบ หรือจะฟ้อง? ถ้าฟ้อง มันจะพูดว่าอะไร?
components/t77/getCounter.ts
5:29 error React Hook "useState" is called in function "getCounter" that is
neither a React function component nor a custom React Hook function.
React component names must start with an uppercase letter.
React Hook names must start with the word "use"
react-hooks/rules-of-hooks
✖ 1 problem (1 error, 0 warnings)
อ่านประโยคสุดท้ายให้ดี: “React Hook names must start with the word "use"”
eslint ไม่ได้วิเคราะห์ว่าโค้ดคุณทำอะไร มันอ่านชื่อ แล้วตัดสินว่าอันไหนคือ hook อันไหนไม่ใช่
(ผมขึ้นบรรทัดใหม่ให้อ่านง่ายเฉย ๆ ของจริง eslint พิมพ์มาเป็นบรรทัดเดียวยาว)
แล้วถ้าดื้อ ไม่เปลี่ยนชื่อ ปล่อยให้ eslint แดงไว้แบบนั้นล่ะ?
โค้ดยังรันได้ปกติ — ผมกดใช้จริงในเบราว์เซอร์แล้ว มันนับเลขได้ไม่มีปัญหา
React ตอนรันไม่เคยดูชื่อฟังก์ชันคุณเลย
การตั้งชื่อ use ไม่ได้ทำให้ฟังก์ชัน "กลายเป็น hook"
มันคือการยกมือบอกเครื่องมือว่า "ฟังก์ชันนี้เป็น hook นะ ช่วยเฝ้าให้ด้วย"
ไม่ยกมือ = eslint เฝ้าให้ไม่ได้ = คุณแหกกฎข้อ 3 ได้ตามสบายโดยไม่มีใครเตือน (เดี๋ยวจะเห็นตัวอย่าง)
กฎ 2 · ต้องเรียก hook ของ React อย่างน้อยหนึ่งตัว
กฎข้อนี้อ่านแล้วงงที่สุด เพราะฟังดูเหมือนข้อบังคับที่ไม่มีเหตุผล
ผมเลยลองแหกดู: เขียนฟังก์ชันชื่อ useFormatBaht ที่ไม่ได้เรียก hook อะไรเลย
แล้วเอาไปเรียกใน if
// components/t77/useFormatBaht.ts — ชื่อขึ้นต้นด้วย use แต่ข้างในไม่มี hook เลย
export function useFormatBaht(n: number) {
return n.toLocaleString("th-TH") + " บาท";
}
// app/t77/rule2/page.tsx (ตัดมาเฉพาะส่วนสำคัญ · ไฟล์เต็มมี "use client" กับ useState อยู่ข้างบน)
if (show) {
console.log(useFormatBaht(1200)); // เรียกใน if
}
โค้ดนี้รันได้ปกติ — มันเป็นฟังก์ชันคำนวณธรรมดา ไม่มี state ให้เรียงลำดับ React ไม่เดือดร้อนอะไร แต่ eslint บอกว่า:
app/t77/rule2/page.tsx
11:17 error React Hook "useFormatBaht" is called conditionally. React Hooks must be
called in the exact same order in every component render
react-hooks/rules-of-hooks
✖ 1 problem (1 error, 0 warnings)
นี่คือ error ปลอม · โค้ดไม่ได้ผิดเลย แต่ eslint ตัดสินจากชื่ออย่างเดียว (กฎข้อ 1)
พอเห็น use ก็สรุปทันทีว่าเป็น hook แล้วบังคับกฎ top-level ใส่
ใส่เสื้อกาวน์เดินในโรงพยาบาล คนจะเข้ามาถามเรื่องคนไข้ พยาบาลจะส่งชาร์ตให้
ถ้าคุณเป็นหมอจริง — ดี ระบบทำงานถูก
ถ้าคุณเป็นช่างแอร์ที่ดันหยิบเสื้อกาวน์มาใส่ — คุณจะโดนถามเรื่องที่คุณไม่เกี่ยวทั้งวัน
และมีกฎของหมอมาบังคับใช้กับคุณโดยที่คุณไม่ได้เป็นหมอ
ที่ไม่เหมือนกัน: ช่างแอร์ใส่เสื้อกาวน์อาจทำให้คนไข้ตายได้ แต่ฟังก์ชันที่ชื่อ use
ผิด ๆ ไม่ทำให้โปรแกรมพัง · มันแค่ทำให้ eslint แดงเป็นข้อ ๆ จนคุณเริ่มชิน
แล้ววันที่มันแดงเพราะเรื่องจริง คุณก็มองข้ามไปด้วย — ความเสียหายคือคุณเลิกเชื่อเครื่องมือ
กฎข้อนี้ไม่ใช่ข้อบังคับทางเทคนิค แต่เป็นข้อตกลงเรื่องการตั้งชื่อ
ถ้าฟังก์ชันไม่มี useState / useEffect / useRef หรือ hook ตัวอื่นอยู่ข้างใน ห้ามตั้งชื่อว่า use…
ตั้งชื่อว่า formatBaht ไปเลย แล้วเรียกที่ไหนก็ได้ตามใจ
กฎ 3 · เรียกที่ระดับบนสุดเท่านั้น — ข้อนี้ React ฟ้องเอง
ทีนี้ลองของจริง: เอา useCounter (ซึ่งมี useState อยู่ข้างในจริง ๆ) ไปเรียกใน if
// app/t77/rule3/page.tsx
"use client";
import { useState } from "react";
import { useCounter } from "@/components/t77/useCounter";
export default function Page() {
const [show, setShow] = useState(false);
if (show) {
const { count } = useCounter(0); // ❌ hook ใน if
console.log(count);
}
return (
<main>
<button onClick={() => setShow(true)}>show</button>
</main>
);
}
เปิดหน้านี้ครั้งแรก show เป็น false → หน้าโหลดขึ้นมาปกติ ปุ่มโผล่ ไม่มี error
แล้วผมกดปุ่ม show หนึ่งครั้ง
คิดว่าเกิดอะไรขึ้น?
หน้าพังทันที ขึ้นกล่องแดงของ Next.js ว่า “This page couldn’t load” · ใน console มีสองข้อความ:
React has detected a change in the order of Hooks called by Page. This will lead to
bugs and errors if not fixed. For more information, read the Rules of Hooks:
https://react.dev/link/rules-of-hooks
Previous render Next render
------------------------------------------------------
1. useState useState
2. undefined useState
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: Rendered more hooks than during the previous render.
ตารางนั้นคือของขวัญ: มันบอกตรง ๆ ว่ารอบก่อนเรียก hook ไป 1 ตัว
รอบนี้เรียก 2 ตัว และตัวที่ 2 ของรอบก่อนคือ undefined (แปลว่าไม่มี)
เจอ error หน้าตานี้เมื่อไหร่ ให้ไปหา if / for / return ที่คร่อม hook อยู่ทันที
eslint ก็จับได้ตั้งแต่ก่อนรัน — ข้อความคนละอันกับกฎข้อ 1 นะ อ่านให้ออกว่าอันไหนคืออันไหน
app/t77/rule3/page.tsx
11:23 error React Hook "useCounter" is called conditionally. React Hooks must be
called in the exact same order in every component render
react-hooks/rules-of-hooks
กับดัก: ถ้าตั้งชื่อผิดกฎข้อ 1 eslint จะเฝ้ากฎข้อ 3 ให้ไม่ได้
ผมเอาโค้ดชุดเดิมเป๊ะ ๆ แต่เปลี่ยนไปเรียก getCounter (ชื่อไม่ขึ้นต้นด้วย use) ใน if แทน
useCounter ใน if | getCounter ใน if | |
|---|---|---|
| eslint ตรงจุดที่เรียก | ❌ ฟ้อง called conditionally |
เงียบสนิท |
| React ตอนกดปุ่ม | 💥 Rendered more hooks than during the previous render. |
💥 Rendered more hooks than during the previous render. (เหมือนกันเป๊ะ) |
ทั้งสองแบบพังเหมือนกันตอนรัน — ต่างกันแค่แบบซ้ายพังตั้งแต่ตอนตรวจ แบบขวาพังตอนผู้ใช้กดปุ่ม
นี่คือคำตอบว่าทำไมกฎข้อ 1 (ตั้งชื่อ use) ถึงต้องมาก่อนกฎข้อ 3
npx next lint ใช้ไม่ได้แล้วใน Next 16
ถ้าคุณเคยเห็นคำสั่ง next lint ในบทความเก่า ๆ — Next 16.3.4 ไม่มีคำสั่งนี้แล้ว
ผมรันดูจริง ได้แบบนี้ (มันเอาคำว่า lint ไปเข้าใจว่าเป็นชื่อโฟลเดอร์):
$ npx next lint
Invalid project directory provided, no such directory: /…/next-sandbox/lint
ของจริงที่ต้องใช้คือเรียก eslint ตรง ๆ:
npx eslint app components หรือ npm run lint
(ใน package.json ที่ create-next-app สร้างให้ สคริปต์ lint คือ "eslint" เฉย ๆ)
กฎ 4 · เรียกสองที่ = ได้ state คนละก้อน (ข้อที่เข้าใจผิดกันมากที่สุด)
มาถึงข้อที่สำคัญที่สุดของบทนี้
คำถามที่เกือบทุกคนตอบผิดครั้งแรกคือ: "กระดาษสูตรมีแผ่นเดียว แล้วตัวเลขล่ะ มีก้อนเดียวไหม?"
ผมเลยทำหน้าจริงขึ้นมา — Box ตัวเดียว เรียก useCounter(0) ข้างใน แล้วเอาไปวางสองครั้ง
// app/t77/page.tsx
"use client";
import { useCounter } from "@/components/t77/useCounter";
function Box({ label }: { label: string }) {
const { count, inc } = useCounter(0);
return (
<p>
<span>{label}</span> = <b>{count}</b>{" "}
<button onClick={inc}>+1</button>
</p>
);
}
export default function Page() {
return (
<main>
<Box label="A" />
<Box label="B" />
</main>
);
}
เปิดหน้า → กดปุ่ม +1 ของ A สามครั้ง → กดปุ่ม +1 ของ B หนึ่งครั้ง
คิดว่าบนจอจะขึ้นตัวเลขอะไร?
ผมกดจริงในเบราว์เซอร์ (dev server ของ Next 16.3.4) แล้วอ่านข้อความบนหน้าออกมาได้ตรง ๆ ว่า:
t77 — useCounter สองที่
A = 3 +1
B = 1 +1
A = 3 · B = 1 — ไม่ใช่ 4 ทั้งคู่ และไม่ใช่ 3 ทั้งคู่
ถึงจะเรียก useCounter ตัวเดียวกัน โค้ดบรรทัดเดียวกัน แต่ตัวเลขเดินแยกกันสนิท
<Box label="A" /> <Box label="B" />
┌──────────────────────────┐ ┌──────────────────────────┐
│ useCounter(0) │ │ useCounter(0) │
│ └─ useState → [ 3 ] │ │ └─ useState → [ 1 ] │
└──────────────────────────┘ └──────────────────────────┘
▲ ▲
└───────────┬───────────────────────┘
│
โค้ดใช้ร่วมกัน (ไฟล์ useCounter.ts มีแผ่นเดียว)
แต่ช่องเก็บค่าแยกกัน
React ผูก state ไว้กับ "ตำแหน่งของ component ในต้นไม้"
ไม่ได้ผูกไว้กับ "ฟังก์ชัน useCounter"
→ วาง Box กี่ตัว ก็ได้ช่องเก็บค่าเท่านั้นตัว
ประโยคสุดท้ายในกล่องคือหัวใจ · useState ไม่ได้เก็บค่าไว้ในตัวฟังก์ชัน
มันไปจองช่องไว้กับ component ที่กำลังวาดอยู่ ณ ตอนนั้น
เพราะฉะนั้นคำถาม "เรียกสองที่แชร์กันไหม" จึงตอบได้เลยว่า ไม่ ตราบใดที่มันอยู่คนละ component
บริษัทมีแบบฟอร์มใบลาแบบเดียว วางไว้ที่ชั้นวางหน้าห้องบุคคล
คุณหยิบมาหนึ่งใบเขียนชื่อตัวเอง เพื่อนหยิบมาอีกใบเขียนชื่อเพื่อน
แบบฟอร์มแบบเดียวกัน แต่ใบที่กรอกแล้วคนละใบ — คุณลา 3 วัน ไม่ได้แปลว่าเพื่อนลา 3 วันด้วย
ชั้นวาง = ไฟล์ useCounter.ts · ใบที่กรอกแล้ว = state ของแต่ละ component
ที่ไม่เหมือนกัน: ใบลาที่กรอกแล้วอยู่กับคน แต่ state อยู่กับตำแหน่งใน component tree
ถ้า React ถอด <Box> ตัวนั้นออกจากหน้าจอ (unmount) ค่าที่กรอกไว้หายทันที ไม่มีใครเก็บให้
เทียบง่าย ๆ คือใบลาโดนฉีกทิ้งทุกครั้งที่คุณเดินออกจากตึก
custom hook ไม่ใช่เครื่องมือแชร์ค่า มันคือเครื่องมือแชร์ วิธีทำ
อยากให้หลาย component เห็นค่าเดียวกัน ต้องยก state ขึ้นไปไว้ที่พ่อแม่ร่วม แล้วส่งลงมาเป็น prop
หรือใช้ useContext ที่เพิ่งเรียนไปในบท 76
ท่ามาตรฐานคือ ใช้ทั้งคู่ — Context เก็บค่ากลาง แล้วห่อการอ่านค่านั้นไว้ใน custom hook ชื่อ useXxx
- rules of hooks
- ชุดกฎที่ React บังคับว่า hook ต้องถูกเรียกจำนวนเท่าเดิมและลำดับเดิมทุกครั้งที่วาดใหม่ เพราะ React จำ state ด้วย "ลำดับที่ถูกเรียก" ไม่ใช่ด้วยชื่อตัวแปร · ถาม senior ได้ว่า “ตรงนี้ผมต้องเรียก hook แบบมีเงื่อนไข ควรแยกเป็น component ย่อยไหม” (คำตอบส่วนใหญ่คือ “ใช่” — แยก component คือทางออกมาตรฐานของปัญหานี้)
- isolated state / per-instance state
- state ที่เป็นของแต่ละที่ที่เรียก ไม่ปนกัน — คือกฎข้อ 4 ที่พิสูจน์ไปข้างบน · พิมพ์บอก AI ได้ว่า “does this custom hook share state between components, or is it per-instance?” ถ้าคำตอบที่ได้บอกว่า “shared” ให้สงสัยไว้ก่อนว่า AI ตอบผิด
- unmount
- จังหวะที่ React ถอด component ออกจากหน้าจอ · ตอนนั้น state ทุกก้อนของมันหายหมด
และ cleanup ของ
useEffectจะถูกเรียก · ถาม senior ได้ว่า “ค่านี้ต้องอยู่รอดตอน unmount ไหม” ถ้าต้องอยู่รอด แปลว่าที่เก็บมันไม่ควรอยู่ใน component ตัวนั้น
เขียน custom hook ที่ใช้ได้จริงหนึ่งตัว — useDebounce
สไลด์หน้า 61 ยกตัวอย่างการใช้งานมา 9 แบบ · อันที่ 6 คือ Debounce / Throttle
พร้อมโค้ดบรรทัดเดียวว่า const debouncedValue = useDebounce(value, 500);
เราจะเขียนตัวนี้กันจริง ๆ เพราะบท 78 (โจทย์ Customer Search) ต้องใช้พอดี
ปัญหาที่มันแก้: ช่องค้นหา ถ้ายิง API ทุกตัวอักษรที่พิมพ์ พิมพ์ 5 ตัวก็ยิง 5 ครั้ง
สิ่งที่เราอยากได้คือ "รอให้เขาหยุดพิมพ์ก่อนสักครึ่งวินาที ค่อยยิงครั้งเดียว"
// components/t77/useDebounce.ts
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // ← บรรทัดนี้คือทั้งหมดของ debounce
}, [value, delay]);
return debounced;
}
กลไกอยู่ที่บรรทัด clearTimeout · ทุกครั้งที่ value เปลี่ยน
React จะเรียก cleanup ของรอบก่อนทิ้งก่อน แล้วค่อยรัน effect รอบใหม่
แปลว่า timer ที่ตั้งไว้เมื่อกี๊โดนยกเลิกเสมอ — จะมี timer รอดชีวิตได้ก็ต่อเมื่อ 500ms ผ่านไปโดยไม่มีใครพิมพ์อะไรเพิ่ม
ผมเอาไปใช้จริงในหน้าเทส แล้วใส่ console.log ไว้สองจุดเพื่อนับ:
// app/t77/debounce/page.tsx (ตัดมาเฉพาะส่วนสำคัญ)
const [text, setText] = useState("");
const debounced = useDebounce(text, 500);
useEffect(() => {
if (text === "") return;
console.log("RAW:", text); // นับทุกตัวอักษรที่พิมพ์
}, [text]);
useEffect(() => {
if (debounced === "") return;
console.log("SEARCH:", debounced); // นับ "ครั้งที่จะยิง API จริง"
}, [debounced]);
พิมพ์คำว่า s → o → l → a → r รวดเดียว (5 ปุ่ม)
แล้วรอ 2 วินาที แล้วเปิด console ดู
คิดว่าจะเห็น RAW: กี่บรรทัด และ SEARCH: กี่บรรทัด?
ผมกดปุ่มทีละตัวจริง ๆ ในเบราว์เซอร์ แล้วอ่าน console ออกมาได้ตรง ๆ ว่า:
RAW: s
RAW: so
RAW: sol
RAW: sola
RAW: solar
SEARCH: solar
RAW 5 บรรทัด · SEARCH 1 บรรทัด
คือถ้าเป็นช่องค้นหาจริง คุณเพิ่งลดจำนวน request จาก 5 เหลือ 1 ด้วย hook 10 บรรทัด
และระหว่างที่พิมพ์อยู่ ผมเช็คค่าบนหน้าจอทันทีหลังพิมพ์เสร็จ ได้ว่า
ที่พิมพ์ = solar แต่ หลังหน่วง = (ยังว่าง) · อีกวินาทีเดียวมันถึงตามมาเป็น solar
- debounce
- “รอให้เงียบก่อนค่อยทำ” — มีเหตุการณ์รัว ๆ เข้ามา แต่เราลงมือแค่ครั้งเดียวหลังจากที่มันหยุดรัวแล้ว (ต่างจาก throttle ที่แปลว่า “ทำได้ไม่เกินนาทีละครั้ง” คือทำระหว่างทางด้วย) · ถาม senior ได้ว่า “ช่องนี้ควร debounce หรือ throttle” ช่องค้นหามักตอบว่า debounce ส่วนการ scroll มักตอบว่า throttle
- cleanup function
- ฟังก์ชันที่
useEffectคืนออกมา เพื่อบอก React ว่า “ตอนเลิกใช้ ให้เก็บกวาดแบบนี้” · React เรียกมันก่อนรัน effect รอบถัดไป และตอน component ถูก unmount · พิมพ์บอก AI ได้ว่า “this effect needs a cleanup function to cancel the pending timer” - generic (
<T>) - ช่องว่างของ type ที่เติมทีหลัง —
useDebounce<T>(value: T, …): Tแปลว่า “ใส่ type ไหนเข้ามาก็คืน type นั้นกลับไป” ทำให้ hook ตัวเดียวใช้ได้กับทั้งstringและnumberโดยไม่เสีย type safety (เรื่องนี้เจอมาแล้วในบท 65) · ถาม senior ได้ว่า “hook ตัวนี้ควรเป็น generic ไหม หรือ fix type ไปเลยดีกว่า”
useUser จากสไลด์หน้า 62 ไปใช้
โค้ดในสกรีนช็อตหน้า 62 เขียนว่า useState(null) เฉย ๆ
ผมก็อปมาทั้งดุ้น มันผ่าน tsc และผ่าน eslint ไม่มีปัญหา — จนกระทั่งเอาไปใช้:
const user = useUser();
return <p>{user?.name}</p>;
// $ npx tsc --noEmit
// app/t77/slide/page.tsx(7,20): error TS2339:
// Property 'name' does not exist on type 'never'.
เพราะ useState(null) ทำให้ TypeScript สรุปว่า user เป็น null ตลอดกาล
พอเขียน user?. มันเลยเหลือ type เป็น never
ที่ถูกคือบอก type ให้ชัด: useState<User | null>(null) —
ผมแก้แบบนี้แล้ว npx tsc --noEmit ผ่านสะอาด
สไลด์ไม่ผิดหรอก เขาเขียน .ts โชว์แนวคิด แต่พอเอามาต่อของจริงแล้วมันไปไม่รอด
ไม่ต้องเขียนเองทุกตัว — useHooks กับ Mantine
สไลด์หน้า 63–64 ปิดท้ายด้วยของสำเร็จรูปสองเจ้า ซึ่งเป็นสองเจ้าที่คนใช้กันจริงในงาน
| เจ้าไหน | เป็นอะไร | ติดตั้ง |
|---|---|---|
| useHooks | ชุด hook สำเร็จรูปจากทีม ui.dev · มีทั้ง useDebounce, useLocalStorage,
useMediaQuery, useCopyToClipboard, useWindowSize |
npm i @uidotdev/usehooks |
| Mantine Hooks | ชุด hook ของ Mantine · สไลด์ระบุว่ามีเกิน 70 ตัว · ใช้ได้แม้ไม่ได้ใช้ component ของ Mantine เพราะแพ็กเกจนี้ไม่พึ่งอะไรนอกจาก React | npm i @mantine/hooks |
📌 บทนี้ไม่ได้ลง sandbox สองตัวนี้ให้ — ผมไม่ได้ติดตั้งมันในโปรเจกต์ทดสอบ
เพราะฉะนั้นข้อมูลในตารางนี้มาจากหน้าเว็บของเขาตามที่สไลด์ถ่ายมา ไม่ใช่ผลรันของผม
ก่อนใช้ในงานจริง ให้เปิดหน้า docs ของเวอร์ชันที่คุณจะติดตั้งเสมอ
แต่ก่อนจะไปหยิบของสำเร็จรูป ให้เขียน useDebounce เองสักครั้งก่อน —
10 บรรทัดข้างบนคือสิ่งที่ทำให้คุณอ่าน source ของไลบรารีพวกนี้ออก แทนที่จะได้แค่ก็อปมาใช้แล้วภาวนา
🛠 ภารกิจ — เขียน useToggle แล้วพิสูจน์ทั้งสองข้อ ห้ามข้ามข้อ 4
สไลด์หน้า 61 ช่องที่ 5 (UI State & Interaction) ยกตัวอย่าง
const [isOpen, open, close, toggle] = useToggle(false); — เราจะเขียนตัวนี้กัน
-
สร้างไฟล์
components/useToggle.tsในโปรเจกต์ Next.js ของคุณ แล้วเขียน hook ที่รับค่าเริ่มต้นเป็นbooleanและคืน{ isOpen, open, close, toggle }ออกมา
สังเกตว่าเราคืนเป็น object ไม่ใช่ array อย่างในสไลด์ — คืน array แล้วคนเรียกต้องจำลำดับ ให้ถูก (สลับopenกับcloseก็ไม่มีใครฟ้อง) ส่วนคืน object คนเรียกจำแค่ชื่อ · ถ้าอยากได้แบบสไลด์เป๊ะ ๆ ให้return [isOpen, open, close, toggle] as const;แทน -
สร้าง component
Panelที่ใช้useToggle(false)แล้วมีปุ่มสลับกับข้อความที่โผล่/หายตามค่า · อย่าลืม"use client"บรรทัดบนสุดของไฟล์ page -
วาง
<Panel />ลงในหน้าเดียวกันสองอัน -
พิสูจน์กฎข้อ 4 ด้วยตาตัวเอง — เปิดอันบน ปล่อยอันล่างปิดไว้
ถ้าอันล่างเปิดตามด้วย แปลว่าคุณเผลอยก state ไปไว้ที่Pageแล้วส่งลงมาเป็น prop (ซึ่งเป็นคนละเรื่องกับ custom hook) — กลับไปดูว่าuseToggleถูกเรียกที่ไหน -
พิสูจน์กฎข้อ 3 ด้วยการทำให้มันพัง — ย้ายบรรทัด
useToggle(false)เข้าไปอยู่ในifสักอัน แล้วรันnpx eslint app components
ก็อปข้อความที่ eslint ฟ้องมาเทียบกับข้อความในบทนี้ ว่าเหมือนกันไหม
แล้วรัน dev server กดปุ่มจนหน้าพัง เพื่อดู error ของ React ด้วยตาตัวเอง แล้วค่อยแก้กลับ -
ปิดท้ายด้วย
npx tsc --noEmitต้องไม่มี error เหลือ
ข้อ 5 คือข้อที่คนข้ามบ่อยที่สุด — แต่เป็นข้อเดียวที่ทำให้คุณจำ error หน้าตานั้นได้ตอนเจอของจริงตอนตีสอง
import { useCallback, useState } from "react";
export function useToggle(initial = false) {
const [isOpen, setIsOpen] = useState(initial);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
return { isOpen, open, close, toggle };
}
ใช้ useCallback เพื่อให้ฟังก์ชันสามตัวนี้เป็นก้อนเดิมทุกรอบ —
เผื่อคุณส่งมันลงไปให้ลูกที่ห่อด้วย React.memo (ตามที่คุยกันไว้ในบท 75)
ในโปรเจกต์นี้ React เป็นเวอร์ชัน 19 และ next.config.ts ไม่ได้เปิด React Compiler ไว้
(ผมเปิดไฟล์ดูแล้ว ข้างก้อน nextConfig ว่างเปล่า มีแต่คอมเมนต์ /* config options here */)
→ useCallback ยังต้องเขียนเองอยู่ ไม่มีใครใส่ให้
ทบทวนด้วยคำถาม
หน้าหนึ่งวาง <Box /> สองอัน ทั้งคู่เรียก useCounter(0) ข้างในเหมือนกัน · กด +1 ที่อันแรกสามครั้ง อันที่สองไม่แตะเลย จะเห็นอะไรบนจอ?
ตัวเลือกที่ 1 ถูก · ผมรันจริงในบทนี้แล้วได้ A = 3 · B = 1 (กด B ไปหนึ่งครั้ง)
ยืนยันว่าแยกกันจริง
ตัวเลือกที่ 2 กับ 3 คือความเข้าใจผิดที่พบบ่อยที่สุด — สับสนระหว่าง "ใช้โค้ดร่วมกัน" กับ "ใช้ค่าร่วมกัน"
useState จองช่องเก็บค่าไว้กับตำแหน่งของ component ในต้นไม้ ไม่ใช่กับตัวฟังก์ชัน
ตัวเลือกที่ 4 ผิดเพราะ hook เก็บ state ให้แน่นอน แค่เก็บแยกกันคนละก้อน
อยากให้แชร์ค่ากันจริง ๆ ต้องยก state ขึ้นไปไว้ที่พ่อแม่ร่วม หรือใช้ useContext
ทำไมชื่อ custom hook ต้องขึ้นต้นด้วย use?
ตัวเลือกที่ 2 ถูก · ในบทนี้ผมเปลี่ยนชื่อเป็น getCounter แล้ว eslint ฟ้องตรง ๆ ว่า
“React Hook names must start with the word "use"”
ตัวเลือกที่ 1 ผิด — ผมปล่อยชื่อผิดไว้แล้วรันจริง โปรแกรมทำงานได้ปกติ
React ตอนรันไม่ได้ดูชื่อฟังก์ชันเลย มันนับแค่ "ลำดับที่ hook ถูกเรียก"
และนั่นแหละคืออันตราย: ชื่อผิด = eslint เฝ้าให้ไม่ได้ = คุณเรียก hook ใน if ได้โดยไม่มีใครเตือน
แล้วไปพังตอนผู้ใช้กดปุ่มแทน
ตัวเลือกที่ 3 กับ 4 ไม่มีอะไรเกี่ยวกับความจริงเลย
ฟังก์ชันชื่อ useFormatBaht(n) ที่ข้างในไม่ได้เรียก hook ของ React สักตัว ถ้าเอาไปเรียกใน if จะเกิดอะไรขึ้น?
ตัวเลือกที่ 3 ถูก · ผมทดลองจริงแล้วได้ React Hook "useFormatBaht" is called conditionally.
ทั้งที่โค้ดไม่ได้ผิดอะไรเลย
ตัวเลือกที่ 1 ผิดครึ่งเดียว — รันได้จริง แต่ eslint ไม่เงียบ เพราะมันตัดสินจากชื่อ ไม่ได้อ่านข้างใน
ตัวเลือกที่ 2 ผิด — React ไม่พัง เพราะไม่มี useState ให้เรียงลำดับสักตัว
ตัวเลือกที่ 4 ผิด — TypeScript ไม่มีความเห็นเรื่องนี้เลย
นี่คือเหตุผลที่กฎข้อ 2 มีอยู่: ถ้าฟังก์ชันไม่มี hook อยู่ข้างใน ห้ามตั้งชื่อว่า use…
ไม่งั้นคุณจะได้ error ปลอมจนเลิกเชื่อ eslint
ใช้ useDebounce(text, 500) แล้วพิมพ์รวดเดียว 5 ตัวอักษร · useEffect ที่ผูกกับค่าหลังหน่วงจะทำงานกี่ครั้ง?
ตัวเลือกที่ 2 ถูก · ผมพิมพ์ s o l a r ทีละปุ่มจริง แล้วอ่าน console ได้
RAW: 5 บรรทัด แต่ SEARCH: 1 บรรทัด
ตัวเลือกที่ 1 คือสิ่งที่จะเกิดถ้าไม่ใช้ debounce — และคือจำนวน request ที่เราพยายามลดอยู่
ตัวเลือกที่ 3 ผิด เพราะ timer ตัวสุดท้ายไม่มีใครมายกเลิก (ไม่มีการพิมพ์เพิ่มแล้ว) มันเลยรอดไปทำงานหลัง 500ms
ตัวเลือกที่ 4 ไม่มีจริง React ไม่รวบ effect ให้ใคร
ถ้าลบบรรทัด return () => clearTimeout(id) ทิ้ง คำตอบจะกลายเป็นข้อ 1 ทันที — cleanup คือทั้งหมดของ debounce
อ่านต่อตรงไหน
- React Docs — Reusing Logic with Custom Hooks —
อ่านหัวข้อ Custom Hooks share stateful logic, not state itself
ซึ่งเป็นหัวข้อที่ตอบกฎข้อ 4 โดยตรง · ในนั้นมีประโยคที่เขาเน้นไว้ว่า hook แต่ละครั้งที่เรียกเป็นอิสระต่อกันโดยสิ้นเชิง
— เทียบกับผลรัน
A = 3 · B = 1ในบทนี้ว่าตรงกันไหม - React Docs — Rules of Hooks —
อ่านหัวข้อ Only call Hooks at the top level ที่ไล่รายการไว้ครบว่าห้ามเรียกใน
if· loop ·try/catch· หลังreturn· และในฟังก์ชันที่ไม่ใช่ component (ลิงก์นี้คือลิงก์เดียวกับที่ React พิมพ์ใน error ตอนหน้าพัง) - React Docs —
useEffect— อ่านหัวข้อ Parameters ตรงคำอธิบายของ cleanup function ที่บอกว่า React เรียก cleanup ของรอบก่อนก่อนรัน effect รอบใหม่เสมอ — ประโยคนั้นคือคำอธิบายว่าทำไมuseDebounce10 บรรทัดถึงทำงานได้ - useHooks (ui.dev) —
เปิดหน้า
useDebounceของเขา แล้วเทียบกับตัวที่เราเขียนในบทนี้บรรทัดต่อบรรทัด ดูว่าเขาเพิ่มอะไรมาบ้าง และคุณต้องการมันไหม - Mantine Hooks —
ไล่รายชื่อในแถบซ้ายสัก 1 นาที (
use-click-outside,use-hotkeys,use-media-query,use-local-storage) เพื่อให้รู้ว่ามีอะไรให้หยิบบ้าง จะได้ไม่เขียนของที่มีคนเขียนไว้ดีแล้ว
💬 ติดตรงไหนถามได้เลย — โดยเฉพาะสามคำถามนี้ ที่ถามกันบ่อยมากหลังบทนี้:
“แล้วถ้าผมอยากให้ Panel สองอันเปิดพร้อมกัน ต้องทำยังไง” ·
“useDebounce กับ setTimeout เฉย ๆ ต่างกันตรงไหน” ·
“custom hook ควรคืน object หรือ array ดี”
ถามมาได้ทุกข้อ ไม่ต้องรอให้ทำภารกิจเสร็จก่อน