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

useMemo และ useCallback — จำผลลัพธ์ไว้ ไม่ต้องคิดใหม่ทุกครั้ง

~25 นาที · ต้องผ่านบท 73 (useState) และ 74 (useEffect) มาก่อน · บทนี้ยาวกว่าปกติเพราะสองในสามของเนื้อหาคือ "เมื่อไหร่ที่ไม่ควรใช้"

สองตัวนี้เป็น hook ที่ถูกใส่ผิดที่บ่อยที่สุดในโค้ด React ทั้งโลก เพราะชื่อมันฟังดูเหมือน "ปุ่มเร่งความเร็ว" ที่ใส่แล้วดีขึ้นเสมอ — ซึ่งไม่จริง
ความจริงคือมันต่างกันแค่เรื่องเดียว: useMemo จำค่า ส่วน useCallback จำฟังก์ชัน · จบแค่นั้น ที่เหลือเหมือนกันหมด
และความจริงข้อที่สองที่แสบกว่า: useCallback ที่ใส่ไว้เฉย ๆ โดยที่ลูกไม่ได้ห่อ React.memo คือโค้ดที่ไม่ทำอะไรเลย นอกจากทำให้ไฟล์ยาวขึ้น
บทนี้จะไม่บอกให้เชื่อ — จะเปิด console นับจำนวนครั้งที่ลูกถูกวาดใหม่ให้ดูกับตา ทั้งแบบใส่และไม่ใส่

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

next          16.3.4 (Turbopack)
react / react-dom  19.2.8
typescript    5
node          v24.14.0
# คำสั่งที่ใช้:
npx tsc --noEmit          # ไม่มี error (ไม่พิมพ์อะไรออกมาเลย)
npx eslint app components  # เอาคำเตือน react-hooks มาโชว์
npx next build            # ✓ Compiled successfully (เวลาที่โชว์ต่างกันทุกรอบ ไม่ต้องเทียบตัวเลข)
npx next dev -p 375       # แล้วกดปุ่มจริงในเบราว์เซอร์ อ่าน console

ทุกบรรทัด console.log · ทุกตัวเลข · และ error ทุกก้อนในบทนี้ ก็อปมาจากผลรันจริงบนเครื่องผม ไม่ได้พิมพ์จากความจำ
โค้ดทุกก้อนที่บอกว่าเป็น "ไฟล์" ผ่าน npx tsc --noEmit และ npx next build จริงทั้งหมด

1 · ก่อนอื่น: React วาดใหม่ทั้งตัว ทุกครั้งที่ state เปลี่ยน

นี่คือรากของทั้งบท ถ้าข้อนี้ไม่แน่น อีกสิบหัวข้อข้างล่างจะกลายเป็นการท่องจำทันที

ตอนคุณกด setCount(1) ในบท 73 สิ่งที่ React ทำคือ เรียกฟังก์ชัน component ของคุณใหม่ทั้งฟังก์ชัน ตั้งแต่บรรทัดแรกถึงบรรทัดสุดท้าย
ไม่ได้แก้เฉพาะตัวเลขบนจอ · ไม่ได้ "อัปเดตแค่ส่วนที่เปลี่ยน" ในระดับโค้ด — โค้ดในตัว component ถูกรันซ้ำหมด

แปลว่าทุกบรรทัดแบบนี้ ถูกทำใหม่ทุกครั้งที่วาด:

function TodoList({ todos, tab }) {
  const visible = filterTodos(todos, tab);   // ← คำนวณใหม่ทุกครั้ง
  const handleClick = () => {};             // ← สร้างฟังก์ชันก้อนใหม่ทุกครั้ง
  // ...
}

ปกติไม่เป็นไร เพราะงานพวกนี้เร็วมาก · แต่มันเริ่มเป็นเรื่องเมื่อ

useMemo กับ useCallback เกิดมาเพื่อสองเคสนี้ เท่านั้น

2 · useMemo — จำ "ค่า" ที่คำนวณได้

สไลด์หน้า 44 เขียนไว้สั้นมากว่า "Cache the result of a calculation between re-renders" — แปลตรงตัวคือ เก็บผลลัพธ์ของการคำนวณไว้ใช้ข้ามการวาด

const cachedValue = useMemo(() => {
  // calculate value
}, [dependencies])

สไลด์หน้า 45 แยกชิ้นส่วนไว้ 3 อย่าง ซึ่งตรงกับของจริงทุกตัว:

ชิ้นส่วนสไลด์เรียกว่าพูดแบบภาษาคน
cachedValue Value ผลลัพธ์ที่ได้จากการเรียกฟังก์ชันคำนวณ — ไม่ใช่ตัวฟังก์ชัน
() => {…} Calculation Function ฟังก์ชันที่ไม่รับอาร์กิวเมนต์ และต้อง return ค่าออกมา
[dependencies] Dependencies ค่าทุกตัวในตัว component ที่ถูกใช้ข้างในการคำนวณ ต้องใส่ให้ครบ

ตัวอย่างของสไลด์หน้า 45–46 คือ TodoList:

const TodoList = ({ todos, tab }) => {
  const visibleTodos = useMemo(() => {
    return filterTodos(todos, tab);
  }, [todos, tab]);

  return (
    <ul>
      {visibleTodos.map(todo => <li key={todo.id}>{todo.text}</li>)}
    </ul>
  );
};

แผนภาพหน้า 46 สรุปลำดับไว้ว่า Tab changed → Recalculate function → Re-render
อ่านให้ดี — มันบอกว่า "คำนวณใหม่" เกิดขึ้นเฉพาะตอน dependency เปลี่ยน ส่วน "วาดใหม่" เกิดได้บ่อยกว่านั้นเยอะ

ของจริง: กดปุ่มที่ไม่เกี่ยวข้อง แล้วดูว่ามันคำนวณใหม่ไหม

ผมสร้างไฟล์นี้ในโปรเจกต์ Next.js จริง (components/MemoDemo.tsx) แล้วรัน:

'use client';

import { useMemo, useState } from 'react';
import { bump } from './counter';   // ตัวนับเล็ก ๆ ที่ผมเขียนเอง

function slowSum(n: number): number {
  console.log(`[คำนวณ] รอบที่ ${bump('calc')} · n=${n}`);
  let total = 0;
  for (let i = 0; i < n; i += 1) total += i;
  return total;
}

export default function MemoDemo() {
  const [n, setN] = useState(1000);
  const [tick, setTick] = useState(0);

  const sum = useMemo(() => slowSum(n), [n]);   // ผูกไว้กับ n ตัวเดียว

  return (
    <div>
      <p>sum={sum} tick={tick}</p>
      <button onClick={() => setN(v => v + 1000)}>เปลี่ยน n</button>
      <button onClick={() => setTick(v => v + 1)}>tick +1</button>
    </div>
  );
}

เปิดหน้าเว็บ แล้วกดปุ่มตามลำดับนี้ · นี่คือ console จริง (ผมแทรกบรรทัด ##### ไว้เองเพื่อคั่นจังหวะ):

[คำนวณ] รอบที่ 1 · n=1000
[คำนวณ] รอบที่ 2 · n=1000
##### B · กด tick +1 (state ที่ไม่เกี่ยวกับ n) #####
##### C · กด เปลี่ยน n #####
[คำนวณ] รอบที่ 3 · n=2000
[คำนวณ] รอบที่ 4 · n=2000

ระหว่างบรรทัด B กับ C ว่างเปล่า — กด tick +1 แล้ว component ถูกวาดใหม่จริง (ตัวเลข tick บนจอเปลี่ยนเป็น 1) แต่ slowSum ไม่ถูกเรียกเลย เพราะ n ยังเป็น 1000 เท่าเดิม
พอกด "เปลี่ยน n" เท่านั้นแหละ มันถึงคำนวณใหม่ — และค่าที่ได้บนจอคือ sum=1999000 ซึ่งคือผลรวม 0 ถึง 1999 จริง ๆ

ทำไม log ขึ้นซ้ำสองบรรทัดทุกครั้ง? ไม่ใช่บั๊กของคุณ — ตอน next dev Next เปิด React Strict Mode ไว้ให้ ซึ่งจงใจเรียกฟังก์ชัน component สองรอบ เพื่อจับโค้ดที่มีผลข้างเคียงแอบแฝง
ตอน next build ผมเห็น log ขึ้นรอบเดียว ในเทอร์มินัล
ดังนั้นเวลาอ่าน console ในโหมด dev ให้ดู "เพิ่มขึ้นหรือไม่เพิ่ม" ไม่ใช่ตัวเลขดิบ

🎯 เทียบให้เห็นภาพ

useMemo คือกระดาษทดที่จดคำตอบข้อยากไว้
ครูให้ทำโจทย์เดิมซ้ำ ๆ ทั้งคาบ — ครั้งแรกคุณคิดจริง แล้วจดคำตอบไว้ข้างโจทย์ ครั้งต่อ ๆ ไปคุณแค่เหลือบดูว่า "โจทย์ยังเป็นตัวเลขชุดเดิมไหม" ถ้าใช่ก็ลอกคำตอบเดิมส่งเลย
"ตัวเลขชุดเดิมไหม" นั่นแหละคือ [dependencies]

ที่ไม่เหมือนกัน: กระดาษทดจริงเก็บคำตอบได้หลายข้อพร้อมกัน แต่ useMemo เก็บได้ค่าเดียว คือค่าล่าสุดเท่านั้น
ถ้า dependency วิ่งกลับไปกลับมา n=1000 → n=2000 → n=1000 มันจะคำนวณใหม่ทั้งสามครั้ง ไม่ได้จำค่าของ 1000 ไว้รอ — ใครคิดว่ามันเป็นตารางแคชย้อนหลังได้ จะประหลาดใจตอนโปรไฟล์

3 · useCallback — จำ "ฟังก์ชัน" ไม่ใช่ค่า

สไลด์หน้า 48 เขียนว่า "Caches a function definition between component re-renders"
สังเกตคำว่า definition — มันไม่เรียกฟังก์ชันของคุณ มันแค่เก็บก้อนฟังก์ชันนั้นไว้

const cachedFn = useCallback(() => {
  // function
}, [dependencies])

ตัวอย่างของสไลด์หน้า 49–50:

const ProductPage = ({ productId, referrer }) => {
  const handleSubmit = useCallback(
    (orderDetails) => {
      post(`/product/${productId}/buy`, { referrer, orderDetails });
    },
    [productId, referrer],
  );

  return <ShippingForm onSubmit={handleSubmit} />;
};

ต่างจาก useMemo ตรงที่ฟังก์ชันข้างในนี้ รับอาร์กิวเมนต์ได้ (orderDetails) เพราะมันคือฟังก์ชันที่จะถูกเรียกทีหลัง ตอนคนกดปุ่ม ไม่ใช่ฟังก์ชันที่ React เรียกให้ตอนนี้

ทำไมต้อง "จำฟังก์ชัน" ในเมื่อโค้ดข้างในเหมือนเดิมทุกครั้ง

เพราะ JavaScript ตัดสินว่าฟังก์ชันสองก้อน "เท่ากัน" หรือไม่ จากว่ามันเป็นก้อนเดียวกันในหน่วยความจำหรือเปล่า ไม่ใช่จากเนื้อโค้ดข้างใน
เขียน () => {} สองครั้ง = ได้ของสองก้อนที่ไม่เท่ากัน แม้จะพิมพ์เหมือนกันเป๊ะ

ตัวช่วยก่อน — seeFn เก็บก้อนฟังก์ชันลง Set แล้วคืนจำนวนก้อนที่ไม่ซ้ำกัน (Set ตัดของซ้ำด้วยกฎเดียวกับ === พอดี) · อยู่ใน components/counter.ts ไฟล์เดียวกับ bump:

const seen = new Map<string, Set<unknown>>();

export function seeFn(label: string, fn: unknown): number {
  let s = seen.get(label);
  if (!s) {
    s = new Set<unknown>();
    seen.set(label, s);
  }
  s.add(fn);
  return s.size;
}

แล้วไฟล์ที่ใช้พิสูจน์จริง (components/IdentityDemo.tsx):

'use client';

import { useCallback, useMemo, useState } from 'react';
import { seeFn } from './counter';

export default function IdentityDemo() {
  const [tick, setTick] = useState(0);

  const plain   = () => {};                        // ไม่ห่ออะไรเลย
  const cached  = useCallback(() => {}, []);        // ห่อด้วย useCallback
  const viaMemo = useMemo(() => () => {}, []);     // ห่อด้วย useMemo แต่คืนฟังก์ชัน

  console.log(
    `[Identity] tick=${tick}` +
    ` · plain มีก้อนที่ไม่ซ้ำกัน ${seeFn('plain', plain)} ก้อน` +
    ` · cached มีก้อนที่ไม่ซ้ำกัน ${seeFn('cached', cached)} ก้อน` +
    ` · viaMemo มีก้อนที่ไม่ซ้ำกัน ${seeFn('viaMemo', viaMemo)} ก้อน`,
  );

  return <button onClick={() => setTick(v => v + 1)}>identity tick +1</button>;
}

โหลดหน้าใหม่ แล้วกดปุ่มสองครั้ง · console จริง:

[Identity] tick=0 · plain มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · cached มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · viaMemo มีก้อนที่ไม่ซ้ำกัน 1 ก้อน
[Identity] tick=0 · plain มีก้อนที่ไม่ซ้ำกัน 2 ก้อน · cached มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · viaMemo มีก้อนที่ไม่ซ้ำกัน 1 ก้อน
[Identity] tick=1 · plain มีก้อนที่ไม่ซ้ำกัน 3 ก้อน · cached มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · viaMemo มีก้อนที่ไม่ซ้ำกัน 1 ก้อน
[Identity] tick=1 · plain มีก้อนที่ไม่ซ้ำกัน 4 ก้อน · cached มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · viaMemo มีก้อนที่ไม่ซ้ำกัน 1 ก้อน
[Identity] tick=2 · plain มีก้อนที่ไม่ซ้ำกัน 5 ก้อน · cached มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · viaMemo มีก้อนที่ไม่ซ้ำกัน 1 ก้อน
[Identity] tick=2 · plain มีก้อนที่ไม่ซ้ำกัน 6 ก้อน · cached มีก้อนที่ไม่ซ้ำกัน 1 ก้อน · viaMemo มีก้อนที่ไม่ซ้ำกัน 1 ก้อน

อ่านสามคอลัมน์นี้ให้ครบ:

🎯 เทียบให้เห็นภาพ

ลูกที่ห่อ React.memo ก็เหมือนยามหน้าตึกที่จำได้แค่ "เลขกล่องพัสดุ"
ทุกครั้งที่คุณเดินมาส่งของ ยามจะดูเลขกล่อง ถ้าเลขเดิมเขาก็โบกมือให้ผ่าน ไม่ต้องยกเข้าไปใหม่
ปัญหาคือคุณเปลี่ยนกล่องใหม่ทุกเช้า ทั้งที่ของข้างในชิ้นเดิม — ยามเลยเห็นเลขไม่ซ้ำสักวัน แล้วสั่งยกเข้าไปใหม่ทุกวัน
useCallback คือการใช้กล่องใบเดิมซ้ำ

ที่ไม่เหมือนกัน: ยามจริงเปิดกล่องดูข้างในได้ถ้าอยากตรวจ แต่ JavaScript เปิดดูไม่ได้เลย — มันไม่มีวิธีถามว่า "ฟังก์ชันสองก้อนนี้ทำงานเหมือนกันไหม" ทำได้แค่ถามว่า "เป็นก้อนเดียวกันหรือเปล่า"
เพราะฉะนั้นอย่าคิดว่า React "ฉลาดพอจะรู้ว่าโค้ดเหมือนเดิม" — มันไม่รู้ และไม่มีวันรู้

🗣 ศัพท์ที่เพิ่งเรียนไป
memoization
เทคนิค "จำผลลัพธ์ของอินพุตชุดเดิมไว้ ไม่คำนวณซ้ำ" — เป็นที่มาของชื่อ useMemo และ React.memo · ถาม senior ได้ว่า "ตรงนี้ควร memoize ไหม หรือปล่อยให้คำนวณใหม่ไปเลยก็ได้" คำถามนี้จะได้คำตอบตรงกว่าถามว่า "ทำยังไงให้เร็วขึ้น"
re-render (วาดใหม่)
การที่ React เรียกฟังก์ชัน component ของคุณใหม่ทั้งฟังก์ชัน · คนละเรื่องกับการที่หน้าจอเปลี่ยนจริง — วาดใหม่แล้วผลออกมาเหมือนเดิม React ก็ไม่แตะ DOM เลย · พิมพ์บอก AI ได้ว่า "why does this component re-render when X changes" จะได้คำตอบที่ตรงจุดกว่าบอกว่า "หน้าเว็บกระตุก"
referential equality (การเท่ากันโดยอ้างอิง)
กฎที่ว่า object · array · function สองก้อนจะ "เท่ากัน" ก็ต่อเมื่อเป็นก้อนเดียวกัน ไม่ใช่ "หน้าตาเหมือนกัน" · ผมรัน node -e 'console.log((()=>{}) === (()=>{}))' ได้ false จริง · ถาม senior ได้ว่า "prop ตัวนี้เสีย referential equality ทุก render ใช่ไหม" — เป็นประโยคที่บอกอาการได้ครบในบรรทัดเดียว
dependency array
อาร์เรย์ตัวหลังใน useMemo / useCallback / useEffect ที่บอก React ว่า "ถ้าค่าพวกนี้เปลี่ยน ให้ทำใหม่" · พิมพ์บอก AI ได้ว่า "check my dependency arrays for missing deps" แล้วเทียบกับที่ eslint บอก อย่าเชื่อฝ่ายเดียว

4 · ตารางเทียบสองตัว — จุดที่ต่างกันมีจุดเดียว

useMemouseCallback
จำอะไร ค่า (ผลลัพธ์ของการคำนวณ) ฟังก์ชัน (ตัวก้อนโค้ด)
React เรียกฟังก์ชันที่ส่งให้ไหม เรียก แล้วเก็บ ผลลัพธ์ ไว้ ไม่เรียก เก็บ ตัวฟังก์ชัน ไว้เฉย ๆ
ฟังก์ชันที่ส่งให้รับอาร์กิวเมนต์ได้ไหม ไม่ได้ — ต้องเป็น () => … เปล่า ๆ ได้ — (orderDetails) => … ก็ได้
ได้อะไรกลับมา ค่าที่ return ออกมา ฟังก์ชันก้อนเดิม
เคสที่ใช้บ่อยที่สุด คำนวณหนัก · หรือทำ object/array ให้คงตัวเพื่อส่งเป็น prop ส่งฟังก์ชันเป็น prop ให้ลูกที่ห่อ React.memo · หรือใส่ใน dependency ของ useEffect
เขียนแทนกันได้ไหม useCallback(fn, deps) = useMemo(() => fn, deps) — ผมรันพิสูจน์แล้วในหัวข้อ 3
dependency array เหมือนกันทุกอย่าง · เทียบทีละช่องด้วยกฎ referential equality
useMemo
  ส่งเข้าไป  :  () => filterTodos(todos, tab)     ← ฟังก์ชันที่ "คืนค่า"
  React ทำ   :  เรียกมัน แล้วเก็บผลไว้
  ได้กลับมา  :  [{id:1}, {id:2}]                  ← ★ ค่า ★

useCallback
  ส่งเข้าไป  :  (o) => post(url, o)               ← ฟังก์ชันที่จะใช้ทีหลัง
  React ทำ   :  ไม่เรียก · เก็บก้อนนี้ไว้เฉย ๆ
  ได้กลับมา  :  (o) => post(url, o)               ← ★ ฟังก์ชัน ★ ก้อนเดิมเป๊ะ

5 · กับดักที่ 1 — useCallback ที่ลูกไม่ได้ห่อ React.memo คือโค้ดที่ไม่ทำอะไรเลย

นี่คือความเข้าใจผิดที่แพงที่สุดของบทนี้ และเป็นเหตุผลที่โค้ดจำนวนมากเต็มไปด้วย useCallback โดยไม่ได้เร็วขึ้นแม้แต่นิดเดียว

useCallback ทำได้อย่างเดียวคือทำให้ prop ที่ส่งลงไปเป็นก้อนเดิม
แต่ ลูกจะสนใจว่า prop เป็นก้อนเดิมหรือไม่ ก็ต่อเมื่อมันถูกห่อด้วย React.memo
ลูกธรรมดา ๆ ที่ไม่ได้ห่อ จะวาดใหม่ทุกครั้งที่พ่อวาดใหม่ ไม่ว่า prop จะเปลี่ยนหรือไม่

ไฟล์ที่ผมใช้พิสูจน์ (components/CallbackDemo.tsx) — ลูกสองตัว ต่างกันแค่ห่อกับไม่ห่อ:

'use client';

import { memo, useCallback, useState } from 'react';
import { bump } from './counter';

// ลูกคนที่ 1 — ไม่ห่ออะไรเลย
function ChildPlain({ onPing }: { onPing: () => void }) {
  console.log(`[ChildPlain] วาดใหม่ครั้งที่ ${bump('plain')}`);
  return <button onClick={onPing}>ChildPlain</button>;
}

// ลูกคนที่ 2 — ห่อด้วย React.memo
const ChildMemo = memo(function ChildMemoInner({ onPing }: { onPing: () => void }) {
  console.log(`[ChildMemo] วาดใหม่ครั้งที่ ${bump('memo')}`);
  return <button onClick={onPing}>ChildMemo</button>;
});

export default function CallbackDemo() {
  const [count, setCount] = useState(0);
  const onPing = useCallback(() => {}, []);   // ← ก้อนเดิมตลอด

  console.log(`[Parent] วาดใหม่ count=${count}`);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+1 (count = {count})</button>
      <ChildPlain onPing={onPing} />
      <ChildMemo  onPing={onPing} />
    </div>
  );
}

ก่อนดูผล — onPing ถูกห่อด้วย useCallback(…, []) แปลว่าเป็นก้อนเดิมตลอดแน่นอน
ถ้ากดปุ่ม +1 สองครั้ง (พ่อวาดใหม่ 2 รอบ) ChildPlain กับ ChildMemo จะขึ้น log กี่บรรทัดต่อคน?

[Parent] วาดใหม่ count=0
[Parent] วาดใหม่ count=0
[ChildPlain] วาดใหม่ครั้งที่ 1
[ChildPlain] วาดใหม่ครั้งที่ 2
[ChildMemo] วาดใหม่ครั้งที่ 1
[ChildMemo] วาดใหม่ครั้งที่ 2
##### A · กด +1 ของ CallbackDemo สองครั้ง #####
[Parent] วาดใหม่ count=1
[Parent] วาดใหม่ count=1
[ChildPlain] วาดใหม่ครั้งที่ 3
[ChildPlain] วาดใหม่ครั้งที่ 4
[Parent] วาดใหม่ count=2
[Parent] วาดใหม่ count=2
[ChildPlain] วาดใหม่ครั้งที่ 5
[ChildPlain] วาดใหม่ครั้งที่ 6

ChildMemo ไม่โผล่อีกเลยหลังบรรทัด ##### — ค้างอยู่ที่ครั้งที่ 2 ตั้งแต่ตอนโหลดหน้า
ส่วน ChildPlain ไต่ต่อไป 3 · 4 · 5 · 6 ทั้งที่ prop ที่มันได้รับเป็นก้อนเดิมเป๊ะ

แปลตรง ๆ ว่า: useCallback ตัวนั้นไม่ได้ช่วย ChildPlain เลยแม้แต่นิดเดียว มันช่วยเฉพาะ ChildMemo
ถ้าในโค้ดคุณไม่มี React.memo อยู่เลยสักตัว แล้วคุณใส่ useCallback ไว้ทั่วไฟล์ — คุณกำลังจ่ายค่าเขียนโค้ดเพิ่มโดยไม่ได้อะไรกลับมา
(บรรทัด ##### ผมพิมพ์ใส่ console เองเพื่อคั่นจังหวะ ไม่ได้มาจาก React)

สองอย่างนี้ต้องมาคู่กัน ไม่งั้นไม่เกิดผล

❌ ใส่ข้างเดียว = ไม่เกิดอะไร

const onPing = useCallback(fn, []);
// ...
<ChildPlain onPing={onPing} />
// ChildPlain ไม่ได้ห่อ memo
// → วาดใหม่ทุกครั้งอยู่ดี

✅ ใส่ครบทั้งคู่ = ลูกหยุดวาด

const onPing = useCallback(fn, []);
const ChildMemo = memo(Child);
// ...
<ChildMemo onPing={onPing} />
// → ข้ามการวาดได้จริง

และกลับกัน — ห่อ React.memo แต่ส่ง onPing ที่ไม่ได้ห่อ useCallback ลงไป ก็ไม่เกิดผลเหมือนกัน เพราะ prop เป็นก้อนใหม่ทุกครั้ง
ต้องครบทั้งสองฝั่ง มิฉะนั้นได้ศูนย์

6 · กับดักที่ 2 — dependency array ผิด แล้วได้ค่าเก่าค้าง

กับดักข้อ 1 ทำให้โค้ดไม่เร็วขึ้น (น่ารำคาญแต่ไม่พัง)
ข้อนี้ทำให้ตัวเลขบนหน้าเว็บผิด ซึ่งแย่กว่ากันเยอะ และหายากกว่าเยอะ

'use client';

import { useMemo, useState } from 'react';

export default function StaleDemo() {
  const [a, setA] = useState(1);
  const [b, setB] = useState(1);

  const bad  = useMemo(() => a + b, [a]);      // ใช้ b แต่ไม่ได้ใส่ b
  const good = useMemo(() => a + b, [a, b]);   // ใส่ครบ

  return (
    <div>
      <p>a={a} b={b} bad={bad} good={good}</p>
      <button onClick={() => setA(v => v + 1)}>a +1</button>
      <button onClick={() => setB(v => v + 1)}>b +1</button>
    </div>
  );
}

เริ่มที่ a=1 b=1 · กด b +1 สองครั้ง แล้วค่อยกด a +1 หนึ่งครั้ง
bad กับ good จะแสดงเลขอะไรในแต่ละจังหวะ? (ระวังจังหวะสุดท้าย มันไม่ตรงกับที่คนส่วนใหญ่เดา)

เริ่มต้น          : a=1 b=1 bad=2 good=2
กด b +1           : a=1 b=2 bad=2 good=3
กด b +1 อีกครั้ง  : a=1 b=3 bad=2 good=4
กด a +1           : a=2 b=3 bad=5 good=5

สองจังหวะกลาง bad ค้างอยู่ที่ 2 ทั้งที่ b ขยับไปถึง 3 แล้ว — เพราะ [a] ไม่เปลี่ยน React จึงคืนค่าที่จำไว้ให้ ไม่เรียกฟังก์ชันคำนวณเลย

จังหวะสุดท้ายคือส่วนที่โหดที่สุด: พอ a ขยับ bad กระโดดจาก 2 ไป 5 ข้ามค่า 3 กับ 4 ไปเลย
บนหน้าเว็บจริง อาการนี้จะออกมาเป็น "ตัวเลขไม่อัปเดต แล้วอยู่ดี ๆ ก็เด้งไปค่าที่ถูก" ซึ่งสืบยากมาก เพราะตอนคุณเปิดดู มันมักจะถูกไปแล้ว

ข่าวดี: มีเครื่องมือฟ้องให้ฟรี ๆ อยู่แล้ว

ผมรัน npx eslint กับไฟล์ทั้งโฟลเดอร์ ได้ผลนี้ตรง ๆ (โปรเจกต์ที่ create-next-app สร้างมี eslint-config-next ติดมาให้แล้ว):

components/StaleDemo.tsx
  9:37  warning  React Hook useMemo has a missing dependency: 'b'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

✖ 1 problem (0 errors, 1 warning)

9:37 คือ บรรทัดที่ 9 ตัวอักษรที่ 37 ของไฟล์ข้างบน — ชี้ตรงไปที่ [a] ไม่ใช่ที่ useMemo · มันบอกตำแหน่งของ อาร์เรย์ที่ใส่ไม่ครบ ให้เลย ไม่ต้องเดา

มันเป็นแค่ warning ไม่ใช่ error แปลว่า npx next build ยังผ่าน และเว็บยัง deploy ขึ้นได้ทั้งที่โค้ดผิด
กฎที่ผมใช้กับตัวเอง: เจอ react-hooks/exhaustive-deps เมื่อไหร่ ให้ถือว่ามันคือ error
ถ้าจะไม่ใส่ dependency ตัวนั้นจริง ๆ ต้องเขียนคอมเมนต์บอกเหตุผลไว้ให้คนต่อไปอ่าน ไม่ใช่ปิดปาก eslint แล้วเดินหนี

7 · กับดักที่ 3 — ใส่ให้ทุกอย่างแล้วช้าลง

ความรู้สึกแรกของทุกคนหลังอ่านสองหัวข้อบนคือ "งั้นใส่มันทุกที่เลยดีกว่า ปลอดภัยไว้ก่อน"
อย่าทำ และเหตุผลไม่ใช่เรื่องสไตล์ แต่เป็นเรื่องต้นทุนที่มีอยู่จริง

useMemo หนึ่งบรรทัด ทำให้เกิดงานพวกนี้ ทุกครั้งที่วาด แม้ตอนที่มันคืนค่าเก่า:

  1. สร้างก้อนฟังก์ชันใหม่ที่ส่งเข้าไป — ข้อนี้ผมพิสูจน์แล้วในหัวข้อ 3 (คอลัมน์ plain ที่ไต่จาก 1 ถึง 6) · useMemo แค่ไม่เรียกมัน แต่มันถูกสร้างไปแล้ว
  2. สร้างอาร์เรย์ dependency ใหม่ — [a, b] คืออาร์เรย์ก้อนใหม่ทุกครั้ง
  3. เทียบทีละช่องกับของรอบก่อน
  4. จองที่เก็บค่าเก่าไว้ตลอดอายุของ component

ถ้าการคำนวณข้างในเป็นแค่ a + b — งานสี่ข้อข้างบนแพงกว่าการบวกเลขเอง แถมยังบวกภาระให้คนอ่านโค้ดอีกต่างหาก

ลำดับที่ควรทำ (เรียงจากบนลงล่าง อย่าข้ามขั้น)

  1. เขียนแบบไม่ใส่ก่อนเสมอ — โค้ดที่อ่านง่ายชนะโค้ดที่ "อาจจะเร็วกว่า"
  2. เจอปัญหาจริง (หน้าสะดุด · พิมพ์แล้วช้า) แล้วค่อยเปิดดู
  3. วัดก่อนแก้ — ใช้ React DevTools Profiler หรือ console.log นับรอบแบบในบทนี้
  4. รู้แล้วว่าใครคือตัวปัญหา ค่อยใส่ useMemo / useCallback + React.memo ตรงจุดนั้น
  5. วัดซ้ำ ว่าดีขึ้นจริง ถ้าไม่ดีขึ้น ให้ถอดออก

ข้อ 5 คือข้อที่คนข้ามมากที่สุด · โค้ดที่ใส่ไว้แล้วไม่มีใครกล้าถอดออก คือหนี้ที่กองเพิ่มขึ้นทุกสัปดาห์

มีข้อยกเว้นข้อเดียวที่ควรใส่ตั้งแต่ต้น — เวลาค่านั้นเป็น object หรือ array ที่ต้องส่งเป็น prop ให้ component ที่ห่อ React.memo หรือต้องใส่ใน dependency array ของ useEffect
สองกรณีนี้ไม่ได้ทำเพื่อ "ความเร็ว" แต่ทำเพื่อ ความถูกต้อง — ไม่ใส่แล้ว useEffect จะยิงทุกรอบไม่หยุด (บท 74 มีตัวอย่างลูปไม่รู้จบให้ดูแล้ว)

8 · เรื่องที่สไลด์ไม่ได้บอก — 'use client' กับ hook สองตัวนี้

ใน App Router ของ Next.js ทุกไฟล์ใน app/ เป็น Server Component โดยปริยาย จนกว่าคุณจะเขียน 'use client' ไว้บรรทัดบนสุด
ตำราส่วนใหญ่จะสรุปสั้น ๆ ว่า "ใช้ hook = ต้องใส่ 'use client'" — ผมลองจริงแล้วไม่ตรงทั้งหมด จึงเขียนตามที่รันได้จริง

ผลรันจริงที่ 1 — useMemo ล้วน ๆ ไม่ error

ไฟล์นี้ ไม่มี 'use client':

// app/t75/oops/page.tsx
import { useMemo } from 'react';

export default function Page() {
  const total = useMemo(() => 1 + 1, []);
  return <p>total = {total}</p>;
}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:PORT/t75/oops
200

$ curl -s http://localhost:PORT/t75/oops | (ตัดแท็กออก)
Create Next App total = 2

$ npx next build
├ ○ /t75/oops          ← ○ (Static) prerendered as static content

ผ่านฉลุย ไม่มี error สักตัว · เหตุผลอยู่ในตัว React เอง — ผมเปิดดูรายการที่ build ฝั่ง server ของ React 19.2.8 ส่งออกมา:

$ node -e "console.log(Object.keys(require('./node_modules/react/cjs/react.react-server.development.js')).sort().join(' '))"

Children Fragment Profiler StrictMode Suspense
__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE cache cacheSignal
captureOwnerStack cloneElement createElement createRef forwardRef isValidElement
lazy memo use useCallback useDebugValue useId useMemo version

ต้องเขียนพาธเป็น ./node_modules/react/cjs/… เท่านั้น · ถ้าเขียนสั้นว่า require('react/cjs/…') จะได้ Error [ERR_PACKAGE_PATH_NOT_EXPORTED] เพราะ package.json ของ React ไม่ได้เปิดพาธย่อยนี้ให้เรียกผ่านชื่อแพ็กเกจ · (ผลข้างบนคือบรรทัดเดียวยาว ๆ ผมตัดขึ้นบรรทัดใหม่เองให้อ่านง่าย ไม่ได้ตัดคำไหนทิ้ง)

useCallback กับ useMemo อยู่ในรายการ ส่วน useState · useEffect ไม่อยู่

แต่ "ใช้ได้" ไม่เท่ากับ "ควรใช้"
Server Component ถูกวาดครั้งเดียวต่อหนึ่ง request แล้วจบ — ไม่มี re-render
ในเมื่อทั้ง useMemo และ useCallback มีไว้ "จำข้ามการวาดรอบถัดไป" และมันไม่มีรอบถัดไป → ใส่ไปก็ไม่ได้อะไร นอกจากทำให้คนอ่านเข้าใจผิดว่าไฟล์นี้เป็น client

ผลรันจริงที่ 2 — พอเติม useState เข้าไป พังทันที

ไฟล์เดิม เติม useState หนึ่งบรรทัด ยังไม่ใส่ 'use client':

import { useMemo, useState } from 'react';

export default function Page() {
  const [n] = useState(2);
  const total = useMemo(() => n + 1, [n]);
  return <p>total = {total}</p>;
}

เปิดหน้านั้นได้ HTTP 500 พร้อมข้อความนี้ (ก็อปมาทั้งดุ้น):

./app/t75/oops2/page.tsx:1:19
Error: You're importing a module that depends on `useState` into a React Server
Component module. This API is only available in Client Components. To fix, mark
the file (or its parent) with the `"use client"` directive.
    Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
> 1 | import { useMemo, useState } from 'react';
    |                   ^^^^^^^^
  2 |
  3 | export default function Page() {
  4 |   const [n] = useState(2);

Ecmascript file had an error

ดูตำแหน่งลูกศร ^^^^^^^^ ให้ดี — มันชี้ที่ useState ไม่ได้ชี้ที่ useMemo ที่อยู่ข้าง ๆ กัน
นี่คือหลักฐานตรง ๆ ว่าตัวที่ผิดกฎคือ useState ตัวเดียว

สรุปให้ใช้ได้จริง: ในทางปฏิบัติคุณจะเจอ useMemo/useCallback คู่กับ useState เสมอ (ไม่มี state ก็ไม่มีการวาดใหม่ให้ต้องจำ)
เพราะฉะนั้น ไฟล์ไหนมีสองตัวนี้ ให้ใส่ 'use client' ไปเลย — ถูกในทางปฏิบัติเกือบร้อยเปอร์เซ็นต์
แต่ตอนนี้คุณรู้ด้วยว่าทำไม ไม่ใช่แค่ท่องว่า "hook ต้องใส่ use client"

9 · แล้ว React Compiler ที่เขาว่ากันล่ะ

คุณจะได้ยินคนพูดบ่อยมากว่า "React 19 มี compiler แล้ว ไม่ต้องเขียน useMemo เองอีกต่อไป"
ผมไม่เดา — เปิดไฟล์คอนฟิกของโปรเจกต์นี้มากางเลย:

// next.config.ts — ทั้งไฟล์ ไม่ได้ตัดอะไรออก
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
};

export default nextConfig;
$ ls node_modules | grep -i react-compiler
(ไม่มีอะไรออกมา — ไม่ได้ติดตั้ง)

ในโปรเจกต์ที่ create-next-app สร้างมาแบบมาตรฐาน React Compiler ไม่ได้เปิดไว้ (ไม่มีบรรทัด reactCompiler ใน next.config.ts และไม่มีแพ็กเกจของมันใน node_modules)
แปลว่า useMemo กับ useCallback ยังจำเป็นอยู่ในโปรเจกต์นี้ ต้องเขียนเอง
วิธีเช็คในโปรเจกต์ของคุณเอง: เปิด next.config.ts หาคำว่า reactCompiler ถ้าไม่เจอ = ยังไม่เปิด · อย่าเชื่อคำบอกเล่า ให้เปิดไฟล์ดู

🗣 ศัพท์ที่เพิ่งเรียนไป
Server Component / Client Component
ไฟล์ที่ React วาดบน server แล้วส่ง HTML มา (ค่าเริ่มต้นของ App Router) กับไฟล์ที่ถูกส่งไปทำงานในเบราว์เซอร์ด้วย (ต้องมี 'use client') · ถาม senior ได้ว่า "component นี้ควรเป็น client หรือ server" แล้วเสริมว่า "มันต้องมี state ไหม" — เกณฑ์ตัดสินอยู่ตรงนั้น
stale value (ค่าเก่าค้าง)
อาการที่หน้าจอโชว์ค่าที่คำนวณจากข้อมูลรอบก่อน เพราะ dependency ไม่ครบ · พิมพ์บอก AI ได้ว่า "I'm getting a stale value from this useMemo, check my deps" แล้วแปะโค้ดไปทั้งก้อน — คำนี้ทำให้คำถามแม่นขึ้นมาก
React.memo
ฟังก์ชันที่ห่อ component (คนละอย่างกับ useMemo ที่เป็น hook) เพื่อบอกว่า "ถ้า prop ทุกตัวเป็นก้อนเดิม ไม่ต้องวาดใหม่" · ถาม senior ได้ว่า "ตรงนี้ควรห่อ React.memo หรือย้าย state ลงไปให้ลึกกว่านี้แทน" — ทางเลือกที่สองมักจะดีกว่าและไม่มีใครนึกถึง
React Compiler
ตัวแปลงโค้ดที่ใส่ memoization ให้อัตโนมัติตอน build เพื่อให้ไม่ต้องเขียนสองตัวนี้เอง · ถาม senior ได้ว่า "โปรเจกต์เราเปิด React Compiler ไว้หรือยัง" — ถ้ายัง ก็แปลว่ายังต้องเขียนมือ

10 · ภารกิจ — พิสูจน์ด้วยตัวนับ ไม่ใช่ด้วยความรู้สึก

🛠 ทำให้ลูกหยุดวาดใหม่ให้ได้ แล้วพิสูจน์ว่ามันหยุดจริง

สร้างโปรเจกต์ใหม่ด้วย npx create-next-app@latest (หรือใช้ของบท 70 ที่มีอยู่แล้วก็ได้) แล้วทำตามลำดับ · ห้ามข้ามข้อ 4 กับ 6

  1. สร้าง components/RenderCount.tsx — ตัวนับที่ใช้ได้ทุกที่ ไม่ต้องลงแพ็กเกจอะไร:
    'use client';
    
    const counts = new Map<string, number>();
    
    export function bump(label: string): number {
      const n = (counts.get(label) ?? 0) + 1;
      counts.set(label, n);
      return n;
    }
  2. สร้างพ่อที่มี useState หนึ่งตัว (ปุ่ม +1) และลูกหนึ่งตัวที่รับ prop เป็นฟังก์ชัน
    ใส่ console.log(`ลูกวาดครั้งที่ ${bump('child')}`) ไว้บรรทัดแรกของลูก
  3. กด +1 สามครั้ง จดเลขที่ได้ไว้ — นี่คือ "ก่อนแก้" ของคุณ (ในโหมด dev อย่าลืมว่า Strict Mode ทำให้เลขเดินทีละ 2)
  4. ใส่ useCallback อย่างเดียวก่อน แล้วกด +1 สามครั้งใหม่
    คุณต้องได้เลขชุดเดิมเป๊ะ — ถ้าได้เลขน้อยลง แปลว่ามีอย่างอื่นเปลี่ยนไปด้วย ให้ย้อนกลับไปหา
    ข้อนี้คือข้อที่สอนว่า "ใส่ข้างเดียวไม่พอ" ด้วยมือคุณเอง ไม่ใช่ด้วยคำพูดของผม
  5. ห่อลูกด้วย memo() เพิ่ม แล้วกด +1 สามครั้ง — คราวนี้เลขต้องหยุดนิ่ง
  6. เขียนเทสพิสูจน์ — อย่าจบแค่ "เห็นแล้วว่านิ่ง" · เพิ่มโค้ดนี้ในลูก แล้วตั้งค่าที่คาดหวังเอง:
    const n = bump('child');
    const want = 2;   // จำนวนครั้งที่ "ควร" วาด (dev = 2 เพราะ Strict Mode)
    console.log(n <= want
      ? `PASS ลูกวาด ${n} ครั้ง (ไม่เกิน ${want})`
      : `FAIL ลูกวาด ${n} ครั้ง แต่ควรไม่เกิน ${want}`);
    แล้ว ทำให้มันขึ้น FAIL ให้ได้หนึ่งครั้ง ด้วยการถอด memo() ออกชั่วคราว
    เทสที่ไม่เคยเห็นมันขึ้น FAIL คือเทสที่คุณยังไม่รู้ว่ามันทำงานหรือเปล่า
  7. ปิดท้ายด้วยสองคำสั่งนี้ ต้องผ่านทั้งคู่:
    npx tsc --noEmit
    npx next build
    แล้วรัน npx eslint app components ดูว่ามี react-hooks/exhaustive-deps โผล่ไหม
// ข้อ 4 — ใส่ useCallback อย่างเดียว ลูกไม่ได้ห่อ memo
##### A · กด +1 ของ CallbackDemo สองครั้ง #####
[ChildPlain] วาดใหม่ครั้งที่ 3
[ChildPlain] วาดใหม่ครั้งที่ 4
[ChildPlain] วาดใหม่ครั้งที่ 5
[ChildPlain] วาดใหม่ครั้งที่ 6      ← ยังเดินอยู่

// ข้อ 5 — ลูกอีกตัวที่ห่อ memo ในหน้าเดียวกัน กดปุ่มเดียวกัน
[ChildMemo] วาดใหม่ครั้งที่ 1
[ChildMemo] วาดใหม่ครั้งที่ 2       ← หยุดตั้งแต่ตอนโหลดหน้า ไม่ขยับอีกเลย

ถ้าของคุณข้อ 4 แล้วเลขหยุดเดิน ทั้งที่ยังไม่ได้ห่อ memo — แปลว่าลูกของคุณอาจไม่ได้รับ prop จากพ่อจริง ๆ หรือพ่อไม่ได้วาดใหม่
ให้ console.log ที่พ่อด้วยหนึ่งบรรทัด แล้วดูว่าพ่อขยับไหม — ไล่จากบนลงล่างเสมอ

ทดสอบความเข้าใจ

1. ข้อไหนอธิบาย "ความต่างระหว่าง useMemo กับ useCallback" ได้ตรงที่สุด?

ตอบตัวเลือกที่ 2 · ต่างกันแค่ "จำค่า" กับ "จำฟังก์ชัน" จบแค่นั้นจริง ๆ ถึงขนาดที่ useCallback(fn, deps) เขียนเป็น useMemo(() => fn, deps) แล้วได้ผลเหมือนกันเป๊ะ — ผมรันพิสูจน์ไว้ในหัวข้อ 3 แล้ว (คอลัมน์ viaMemo ค้างที่ 1 ก้อนเท่ากับ cached)

ตัวเลือกที่ 1 ผิดเพราะไม่เกี่ยวกับความหนักเบาของงาน · ตัวเลือกที่ 3 ผิดสองชั้น — ทั้งคู่ใช้ได้ทั้งสองฝั่ง (ผมเปิด export ของ React ฝั่ง server ให้ดูในหัวข้อ 8) · ตัวเลือกที่ 4 ผิดเพราะทั้งคู่รับ dependency array เหมือนกันทุกประการ
จุดที่ต้องจำ: useMemo จำค่า useCallback จำฟังก์ชัน — ต่างกันแค่นี้จุดเดียว

2. พ่อมี const onPing = useCallback(() => {}, []) แล้วส่งให้ลูกที่ไม่ได้ห่อ React.memo · กดปุ่มให้พ่อวาดใหม่ 2 รอบ ลูกจะเป็นยังไง?

ตอบตัวเลือกที่ 3 · console จริงในหัวข้อ 5 แสดงชัดว่า ChildPlain ไต่ต่อไป 3 · 4 · 5 · 6 หลังกดปุ่มสองครั้ง ทั้งที่ onPing เป็นก้อนเดิมเป๊ะ ส่วน ChildMemo ในหน้าเดียวกันหยุดนิ่งที่ 2

ตัวเลือกที่ 1 คือความเข้าใจผิดที่บทนี้ตั้งใจมาแก้โดยตรง · ตัวเลือกที่ 2 ผิดเพราะ React ไม่ได้จำอะไรให้เองเลยถ้าคุณไม่สั่ง · ตัวเลือกที่ 4 ผิด — ผมรัน npx next build แล้วผ่านเงียบ ๆ ไม่มีใครเตือนสักคำ นี่แหละคือเหตุผลที่โค้ดแบบนี้อยู่รอดในโปรเจกต์จริงได้นานเป็นปี
จุดที่ต้องจำ: useCallback ที่ไม่มี React.memo รออยู่ปลายทาง คือโค้ดที่ไม่ทำอะไรเลย

3. const bad = useMemo(() => a + b, [a]); โดยเริ่มที่ a=1 b=1 · กด b +1 สองครั้ง (จน b=3) แล้วกด a +1 หนึ่งครั้ง · bad จะเป็นเท่าไหร่ในแต่ละจังหวะ?

ตอบตัวเลือกที่ 1 · ค่าที่อ่านจากหน้าเว็บจริงคือ bad=2 → bad=2 → bad=2 → bad=5
ตอน a เปลี่ยน React ถึงเรียกฟังก์ชันคำนวณใหม่ แล้วมันไปหยิบ b ตัวล่าสุด (3) มาบวก จึงได้ 2 + 3 = 5 ทันที โดยไม่เคยผ่าน 3 กับ 4 เลย

ตัวเลือกที่ 2 ผิดเพราะนั่นคือพฤติกรรมของ good ที่ใส่ [a, b] ครบ — ถ้า bad เดินตามนั้นได้ก็แปลว่า dependency array ไม่มีความหมายอะไรเลย · ตัวเลือกที่ 3 ผิดตรงที่มันไม่ได้ "ล็อกถาวร" — พอ [a] ขยับเมื่อไหร่ก็คำนวณใหม่ทันที · ตัวเลือกที่ 4 ผิด — ไม่มี error ตอนรันเลย มีแค่ warning ของ eslint (react-hooks/exhaustive-deps) และ npx next build ก็ยังผ่าน
จุดที่ต้องจำ: dependency ขาดไม่ได้ทำให้โปรแกรมพัง แต่ทำให้ตัวเลขผิดเงียบ ๆ แล้วเด้งกลับมาถูกเองตอนที่ dependency ตัวอื่นขยับ

4. ไฟล์ app/page.tsx ที่ไม่มี 'use client' แล้วเรียก useMemo อย่างเดียว (ไม่มี useState) — เกิดอะไรขึ้นตอนเปิดหน้านั้น?

ตอบตัวเลือกที่ 4 · ผมรันแล้วได้ HTTP 200 · หน้าแสดง total = 2 ปกติ และ npx next build ยังทำเป็นหน้า static (○) ให้ด้วยซ้ำ
เพราะ build ฝั่ง server ของ React 19.2.8 มี useMemo · useCallback · useId อยู่ในรายการ export จริง (ผมเปิดดูมาให้แล้วในหัวข้อ 8) — ที่ไม่มีคือ useState กับ useEffect

แต่ ไม่มีประโยชน์ เพราะ Server Component วาดครั้งเดียวต่อ request ไม่มีรอบถัดไปให้จำ · ตัวเลือกที่ 1 ผิดเพราะเหมารวมว่า "hook ทุกตัว" · ตัวเลือกที่ 2 ผิด — Next ไม่เคยเติม directive ให้ใครทั้งนั้น · ตัวเลือกที่ 3 ผิดเพราะ build ผ่านฉลุย · ตัวที่พังคือ useState เท่านั้น และ error ก็ชี้ลูกศรไปที่คำว่า useState ตรง ๆ
จุดที่ต้องจำ: เส้นแบ่ง server/client ไม่ได้ขีดที่คำว่า "hook" แต่ขีดที่ "ต้องจำอะไรข้ามการวาดไหม" — useState ต้อง ส่วน useMemo ไม่ต้อง

💬 ติดตรงไหนถามได้ — ถ้ารันตามในบทแล้วได้ผลไม่ตรงกับที่เขียนไว้ ให้ส่งมาสามอย่าง: โค้ดที่รัน · คำสั่งที่ใช้ · และ console ทั้งก้อน (อย่าตัดให้เหลือบรรทัดเดียว — ลำดับของ log คือเบาะแสหลัก)
และคำถามที่คุ้มที่สุดที่ถามได้กับบทนี้คือ "ตรงนี้ควรใส่ useMemo หรือควรย้าย state ลงไปให้ลึกกว่านี้แทน" — เพราะคำตอบที่ถูกบ่อยที่สุดคือ "ย้าย state" ไม่ใช่ "ใส่ memo"

อ้างอิง