บทเรียน 77 · Frontend · Next.js

custom hooks — เอา logic ที่ใช้ซ้ำออกมาเป็นของตัวเอง

~20 นาที · ควรผ่าน บทเรียน 76 (useContext) มาก่อน

สไลด์หน้า 60–64 บอกว่า custom hook คือการ extract and reuse stateful logic แล้วให้กฎมา 4 ข้อ
สามข้อแรกอ่านแล้วเข้าใจได้ทันที แต่ข้อที่ 4 คือข้อที่มือใหม่เข้าใจผิดกันเกือบทุกคน — "เรียก hook ตัวเดียวกันสองที่ แล้วมันแชร์ค่ากันไหม?"
บทนี้ไม่ตอบด้วยคำอธิบายอย่างเดียว แต่เอาผลรันจริงมาวางให้ดู ทั้งตัวเลขบนหน้าจอ ข้อความที่ eslint ฟ้อง และ error ที่ React โยนออกมาตอนคุณแหกกฎ
จบบทนี้คุณจะมี useDebounce ที่ใช้ได้จริงหนึ่งตัว — ซึ่งจะได้ใช้ต่อในบท 78 พอดี

🧰 ของที่ใช้ตรวจในบทนี้

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 ตอนรันไม่เคยดูชื่อฟังก์ชันคุณเลย

✅ สรุปกฎข้อ 1 แบบที่เอาไปใช้ได้

การตั้งชื่อ 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 แดงเป็นข้อ ๆ จนคุณเริ่มชิน แล้ววันที่มันแดงเพราะเรื่องจริง คุณก็มองข้ามไปด้วย — ความเสียหายคือคุณเลิกเชื่อเครื่องมือ

✅ สรุปกฎข้อ 2 แบบที่เอาไปใช้ได้

กฎข้อนี้ไม่ใช่ข้อบังคับทางเทคนิค แต่เป็นข้อตกลงเรื่องการตั้งชื่อ
ถ้าฟังก์ชันไม่มี 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 ใน ifgetCounter ใน 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); — เราจะเขียนตัวนี้กัน

  1. สร้างไฟล์ components/useToggle.ts ในโปรเจกต์ Next.js ของคุณ แล้วเขียน hook ที่รับค่าเริ่มต้นเป็น boolean และคืน { isOpen, open, close, toggle } ออกมา
    สังเกตว่าเราคืนเป็น object ไม่ใช่ array อย่างในสไลด์ — คืน array แล้วคนเรียกต้องจำลำดับ ให้ถูก (สลับ open กับ close ก็ไม่มีใครฟ้อง) ส่วนคืน object คนเรียกจำแค่ชื่อ · ถ้าอยากได้แบบสไลด์เป๊ะ ๆ ให้ return [isOpen, open, close, toggle] as const; แทน
  2. สร้าง component Panel ที่ใช้ useToggle(false) แล้วมีปุ่มสลับกับข้อความที่โผล่/หายตามค่า · อย่าลืม "use client" บรรทัดบนสุดของไฟล์ page
  3. วาง <Panel /> ลงในหน้าเดียวกันสองอัน
  4. พิสูจน์กฎข้อ 4 ด้วยตาตัวเอง — เปิดอันบน ปล่อยอันล่างปิดไว้
    ถ้าอันล่างเปิดตามด้วย แปลว่าคุณเผลอยก state ไปไว้ที่ Page แล้วส่งลงมาเป็น prop (ซึ่งเป็นคนละเรื่องกับ custom hook) — กลับไปดูว่า useToggle ถูกเรียกที่ไหน
  5. พิสูจน์กฎข้อ 3 ด้วยการทำให้มันพัง — ย้ายบรรทัด useToggle(false) เข้าไปอยู่ใน if สักอัน แล้วรัน npx eslint app components
    ก็อปข้อความที่ eslint ฟ้องมาเทียบกับข้อความในบทนี้ ว่าเหมือนกันไหม
    แล้วรัน dev server กดปุ่มจนหน้าพัง เพื่อดู error ของ React ด้วยตาตัวเอง แล้วค่อยแก้กลับ
  6. ปิดท้ายด้วย 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

อ่านต่อตรงไหน

💬 ติดตรงไหนถามได้เลย — โดยเฉพาะสามคำถามนี้ ที่ถามกันบ่อยมากหลังบทนี้:
“แล้วถ้าผมอยากให้ Panel สองอันเปิดพร้อมกัน ต้องทำยังไง” · “useDebounce กับ setTimeout เฉย ๆ ต่างกันตรงไหน” · “custom hook ควรคืน object หรือ array ดี”
ถามมาได้ทุกข้อ ไม่ต้องรอให้ทำภารกิจเสร็จก่อน