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

useEffect — สั่งให้ทำอะไรบางอย่างหลังหน้าวาดเสร็จ

~25 นาที · ต้องผ่าน บทเรียน 73 (useState) มาก่อน · ถ้าจำ defer จาก บทเรียน 08 ได้ จะอ่านหัวข้อ cleanup สบายขึ้นมาก

useState ทำให้ component จำอะไรได้ · useEffect ทำให้ component ไปยุ่งกับโลกข้างนอกได้ — ยิงขอข้อมูลจาก API · ตั้งนาฬิกา · เปิด WebSocket
มันคือ hook ที่คนพังมากที่สุด เพราะหน้าตามันดูเหมือนแค่ "โค้ดที่รันตอนเปิดหน้า" แต่จริง ๆ มันรันหลังการวาดทุกครั้งที่ของใน dependency array เปลี่ยน ซึ่งไม่ใช่เรื่องเดียวกันเลย
บทนี้จะรันกับดัก 4 อันให้ดูของจริงในเบราว์เซอร์ รวมถึงอันที่ทำให้คนตกใจที่สุด — ใส่ [] ว่างแล้วยังรันสองครั้ง — และอันที่โค้ดในสไลด์เองก็ติด ตอนเอามาวางใน Next.js

🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 5 กันยายน 2026)

next        16.3.4   # Turbopack
react       19.2.8
react-dom   19.2.8
typescript  5.9.3    # จาก npx tsc -v ในโปรเจกต์
node        v24.14.0

ทุกกล่องที่เขียนว่า "ได้ผลว่า…" ในบทนี้มาจากของสี่อย่างนี้:
npx next build — ข้อความ error ก็อปมาจาก Turbopack ตัวจริง
npx next dev + console ของเบราว์เซอร์จริง — บรรทัด log ทุกบรรทัดในบทนี้ถ่ายมาจากที่นั่น
npx next start — ใช้เทียบว่า production ต่างจาก dev ยังไง
npx eslint — ใช้ config ที่ create-next-app แถมมาให้ ไม่ได้เพิ่มกฎเอง
โค้ดทุกก้อนอยู่ในโปรเจกต์เดียวกันจริง ๆ · npx tsc --noEmit ผ่านหมด ยกเว้นก้อนที่ตั้งใจให้พัง ซึ่งจะเขียนกำกับไว้ทุกครั้ง

1 · useEffect ทำอะไร

ลองนึกถึงลำดับที่เกิดขึ้นจริงเวลาหน้าเว็บ React แสดงผลหนึ่งครั้ง — สไลด์วาดไว้เป็นสามขั้น:

  React     ┌──────────────────┐
            │   Renders JSX    │   เอา state + props มาคำนวณว่าหน้าตาควรเป็นยังไง
            └────────┬─────────┘
                     ▼
  Browser   ┌──────────────────┐
            │  Render Screen   │   เบราว์เซอร์วาดจริงลงจอ ผู้ใช้เห็นแล้วตอนนี้
            └────────┬─────────┘
                     ▼
  Then      ┌──────────────────┐
            │  Effect trigger  │   <-- useEffect ทำงานตรงนี้ หลังผู้ใช้เห็นหน้าแล้ว
            └──────────────────┘

จุดสำคัญคือคำว่า "หลัง" — โค้ดใน useEffect ไม่ได้รันตอนกำลังคำนวณหน้าตา แต่รันหลังจากเบราว์เซอร์วาดเสร็จแล้ว
นั่นแปลว่าคุณเอาไปทำอะไรที่ "ช้าหน่อยก็ไม่เป็นไร ผู้ใช้เห็นหน้าไปก่อนแล้ว" ได้ เช่นยิง API

โครงของมันมีแค่นี้ — สองส่วนที่ต้องส่งเข้าไป:

useEffect(() => {
  // setup — โค้ดที่อยากให้ทำ
}, [dependencies]);

ก้อนแรกคือฟังก์ชัน · ก้อนที่สองคืออาร์เรย์
อาร์เรย์ตัวนั้นแหละคือของที่ทำให้คนพัง — มันคือคำตอบของคำถาม "ให้รันใหม่ตอนไหน" ไม่ใช่ "ให้ส่งค่าอะไรเข้าไป"

🧰 เทียบให้เห็นภาพ — ป้ายฝากเวรกับ รปภ.

คุณเขียนโน้ตวางไว้ที่โต๊ะ รปภ. ว่า "เวรกลางคืน ช่วยเปิดไฟหน้าตึกให้ด้วย" แล้วกลับบ้าน — คุณไม่ได้เปิดไฟเอง คุณแค่ฝากคำสั่งไว้
โน้ตแผ่นนั้นมีอีกบรรทัดข้างล่างเขียนว่า "ทำใหม่ทุกครั้งที่เปลี่ยนกะ" — นั่นคือ dependency array
ถ้าบรรทัดนั้นเขียนว่า "ทำครั้งเดียวพอ" ก็คือ [] ว่าง · ถ้าเขียนว่า "ทำใหม่ทุกครั้งที่ห้องประชุมเปลี่ยน" ก็คือ [roomId]

ที่ไม่เหมือนกัน: รปภ. อ่านโน้ตแล้วใช้วิจารณญาณได้ ถ้าคุณเขียนกำกวมเขาจะโทรถาม
แต่ React ไม่ถามอะไรทั้งนั้น — มันเอา dependency array ไปเทียบทีละช่อง ด้วย Object.is ตรง ๆ ช่องไหนไม่เหมือนรอบก่อนก็รันใหม่ จบ
และมันไม่รู้ด้วยว่าโค้ดข้างในคุณใช้ตัวแปรอะไรบ้าง — คุณเป็นคนบอกเอง บอกผิดก็รันผิด โดยไม่มีใครทัก (ยกเว้น ESLint ซึ่งเดี๋ยวเราจะเปิดดูในหัวข้อ 8)

🗣 ศัพท์สองคำที่ต้องได้ก่อนไปต่อ
effect · side effect
งานที่ไปยุ่งกับของนอกตัว component — ยิง network · แตะ DOM · ตั้ง timer · เขียน localStorage · ตรงข้ามกับการคำนวณค่าเพื่อ return ออกไปเฉย ๆ · ถาม senior ได้ว่า "อันนี้เป็น effect จริงไหม หรือคำนวณตอน render ไปเลยก็ได้" คำถามนี้ตัด useEffect ที่ไม่จำเป็นออกได้เยอะมาก
dependency array · exhaustive deps
อาร์เรย์ตัวที่สองของ useEffect · คำว่า exhaustive แปลว่า "ครบถ้วน" — คือกฎที่ว่าทุกค่าที่ effect อ่าน ต้องอยู่ในอาร์เรย์ · พิมพ์บอก AI ได้ว่า "fix the exhaustive-deps warning without disabling the rule" ถ้าไม่เติมท่อนหลัง มันชอบแก้ด้วยการใส่ // eslint-disable-next-line ซึ่งคือการซุกปัญหา

2 · ก่อนอื่น — ไม่มี 'use client' คือจบตั้งแต่ build

ใน App Router ของ Next.js ทุกไฟล์เป็น Server Component โดยปริยาย แปลว่ามันรันบนเซิร์ฟเวอร์แล้วส่ง HTML ที่เสร็จแล้วมาให้เบราว์เซอร์
ของบนเซิร์ฟเวอร์ไม่มี "หลังหน้าวาดเสร็จ" ให้รอ — useEffect จึงใช้ในนั้นไม่ได้

// app/t74/broken/page.tsx  — สังเกตว่าไม่มี 'use client' บรรทัดบนสุด
import { useEffect, useState } from 'react';

export default function Page() {
  const [msg, setMsg] = useState('ยังไม่ทำงาน');

  useEffect(() => {
    setMsg('effect ทำงานแล้ว');
  }, []);

  return <p>{msg}</p>;
}

สั่ง npx next build แล้วคิดว่าได้อะไร — ผ่านแล้วค่อยไปพังตอนเปิดหน้า หรือพังตั้งแต่ build?

พังตั้งแต่ build และพัง 2 error ไม่ใช่อันเดียว — อันหนึ่งของ useEffect อีกอันของ useState:

> Build error occurred
Error: Turbopack build failed with 2 errors:
./app/t74/broken/page.tsx:2:10
Error: You're importing a module that depends on `useEffect` 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 | // app/t74/broken/page.tsx  — สังเกตว่าไม่มี 'use client' บรรทัดบนสุด
> 2 | import { useEffect, useState } from 'react';
    |          ^^^^^^^^^
  3 |
  4 | export default function Page() {
  5 |   const [msg, setMsg] = useState('ยังไม่ทำงาน');

Ecmascript file had an error

(บรรทัด Error: You're importing… ของจริงเป็นบรรทัดเดียวยาว ๆ ผมตัดขึ้นบรรทัดใหม่ให้อ่านง่าย นอกนั้นก๊อปมาตามตัว · error อันที่สองหน้าตาเหมือนกันเป๊ะ เปลี่ยนแค่ useEffect เป็น useState และตำแหน่งเป็น 2:21)

อ่าน error ตัวนี้ให้ชิน เพราะคุณจะเจอมันบ่อยมากในสองสัปดาห์แรก
สังเกตคำว่า "importing a module that depends on" — มันไม่ได้บ่นว่าคุณเรียก hook แต่บ่นว่าคุณ import มันเข้ามาในไฟล์ฝั่งเซิร์ฟเวอร์ · แค่ import ทิ้งไว้เฉย ๆ ก็พังแล้ว
วิธีแก้คือเติม 'use client' เป็นบรรทัดแรกสุดของไฟล์ (ก่อน import ทุกตัว) หรือย้ายส่วนที่ใช้ hook ออกไปเป็น component ลูกที่มี 'use client' แล้วให้หน้าเรียกใช้

ท่ามาตรฐานที่ใช้ทั้งบทนี้: หน้าเว็บ (app/…/page.tsx) ปล่อยเป็น Server Component ไว้ แล้วแยกเฉพาะชิ้นที่ต้องใช้ hook ออกไปเป็นไฟล์ใน components/ ที่ขึ้นต้นด้วย 'use client'
ทำแบบนี้แล้ว JavaScript ที่ต้องส่งไปเบราว์เซอร์จะน้อยลง เพราะส่งไปแค่ชิ้นที่ต้องใช้จริง

3 · dependency array — สามแบบ และมันต่างกันจริง

เขียนแบบรันตอนไหนใช้ตอนไหน
useEffect(fn)
(ไม่ใส่อาร์เรย์เลย)
หลังการวาดทุกครั้ง ไม่ว่าอะไรจะเปลี่ยน เกือบไม่มี — และเป็นกับดักอันดับหนึ่ง (หัวข้อ 4)
useEffect(fn, []) หลังการวาดครั้งแรกครั้งเดียว
(แต่ใน dev รันสองครั้ง — หัวข้อ 5)
ตั้งค่าครั้งเดียวจบ เช่นเปิด connection ตอนเข้าหน้า
useEffect(fn, [a, b]) ครั้งแรก + ทุกครั้งที่ a หรือ b เปลี่ยนค่า ส่วนใหญ่ใช้อันนี้ — "ข้อมูลเปลี่ยน ให้ไปโหลดใหม่"

ตัวอย่างในสไลด์คือห้องแชท — เปลี่ยนห้องเมื่อไหร่ ต้องตัดสายห้องเก่าแล้วต่อสายห้องใหม่
นี่คือไฟล์ที่ผมรันจริง (components/t74/ChatRoom.tsx) ตัด createConnection ปลอมออกให้ดูเฉพาะส่วนสำคัญ:

'use client';

import { useEffect, useState } from 'react';

export default function ChatRoom() {
  const [roomId, setRoomId] = useState('general');
  const serverUrl = 'https://localhost:1234';

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);  // setup
    connection.connect();
    return () => connection.disconnect();                    // cleanup
  }, [serverUrl, roomId]);                                  // dependencies

  return (
    <div>
      <p>ห้องปัจจุบัน: {roomId}</p>
      <button onClick={() => setRoomId('random')}>ย้ายไปห้อง random</button>
      <button onClick={() => setRoomId('support')}>ย้ายไปห้อง support</button>
    </div>
  );
}

เปิดหน้านี้ด้วย npx next dev แล้วกดปุ่มสองปุ่มตามลำดับ · นี่คือ console จริง ครบทุกบรรทัดของเรา ไม่ได้ตัดอะไรออกเลย (ตัดออกแค่สองบรรทัดที่ Next.js กับ React ขึ้นให้ทุกหน้าอยู่แล้ว คือ Download the React DevTools… กับ [HMR] connected):

✅ เชื่อมต่อห้อง general ที่ https://localhost:1234
❌ ตัดการเชื่อมต่อห้อง general
✅ เชื่อมต่อห้อง general ที่ https://localhost:1234
❌ ตัดการเชื่อมต่อห้อง general
✅ เชื่อมต่อห้อง random ที่ https://localhost:1234
❌ ตัดการเชื่อมต่อห้อง random
✅ เชื่อมต่อห้อง support ที่ https://localhost:1234

มีสองเรื่องซ่อนอยู่ในผลนี้ และทั้งสองเรื่องคือหัวข้อถัดไป:

สไลด์หน้า 41 เขียนไว้ไม่ครบ — สไลด์บอกว่า cleanup "runs one final time after your component is removed from the page (unmounts)"
ผลรันข้างบนแย้งไปแล้ว: ตอนกดย้ายห้อง component ยังอยู่บนหน้าดี ๆ แต่ cleanup ก็รัน เพราะ dependency เปลี่ยน
ประโยคที่ถูกกว่าคือ: "cleanup รันก่อน setup รอบถัดไปเสมอ และรันอีกครั้งตอน component หายไปจากหน้า"

4 · กับดัก 1 — ลืม dependency array แล้วมี setState ข้างใน

อันนี้คือกับดักที่มือใหม่ตกกันแทบทุกคน เพราะโค้ดมันอ่านดูสมเหตุสมผลมาก:

'use client';

import { useEffect, useState } from 'react';

export default function NoDeps() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('[NoDeps] effect ทำงาน count =', count);
    setCount(count + 1);
  });                       // <-- ลืมใส่ [] ตรงนี้

  return <p>count = {count}</p>;
}

เปิดหน้านี้ทิ้งไว้ 3 วินาที คิดว่า count จะขึ้นไปถึงเท่าไหร่?

ผมเปิดหน้าจริงแล้วอ่านค่าบนจอตอน 3 วินาทีพอดี ได้ count = 7439 และมันยังวิ่งต่อไปเรื่อย ๆ ไม่หยุด
(รันซ้ำอีกรอบแล้วปล่อยนานกว่านั้น เห็นตัวเลขทะลุ 12,000 ไปแล้ว — ตัวเลขไม่เท่ากันทุกครั้ง เพราะขึ้นกับความเร็วเครื่อง แต่ระดับหมื่นภายในไม่กี่วินาที คือของแน่นอน)

React ก็ด่าออกมาใน console ด้วย ข้อความเต็ม ๆ ตามนี้:

Maximum update depth exceeded. This can happen when a component calls
setState inside useEffect, but useEffect either doesn't have a dependency
array, or one of the dependencies changes on every render.

วงจรที่เกิดขึ้นคือ: วาดเสร็จ → effect รัน → setCount → state เปลี่ยน → ต้องวาดใหม่ → วาดเสร็จ → effect รันอีก → … วนอย่างนี้เร็วที่สุดเท่าที่เครื่องจะทำได้
สังเกตว่า React ไม่ได้หยุดให้ — มันแค่ log error แล้วปล่อยวนต่อ พัดลมโน้ตบุ๊กจะดังขึ้นภายในไม่กี่วินาที

✗ ไม่มีอาร์เรย์ = "รันทุกครั้ง"

useEffect(() => {
  setCount(count + 1);
});

วนไม่รู้จบทันที เพราะตัวมันเองเป็นคนทำให้ต้องวาดใหม่

✓ ใส่ [] = "ครั้งแรกครั้งเดียว"

useEffect(() => {
  setCount(count + 1);
}, []);

หยุดวนแล้ว — แต่ยังไม่ใช่โค้ดที่ดี เดี๋ยวหัวข้อ 8 จะบอกว่าทำไม

โค้ดชุดไหนต่อไปนี้ ไม่ ทำให้เกิดการวนไม่รู้จบ ทั้งที่ไม่ได้ใส่ dependency array?

ตอบ ตัวเลือกที่ 2 — ตัวจุดชนวนวงจรคือการเปลี่ยน state ไม่ใช่การไม่ใส่อาร์เรย์
ไม่ใส่อาร์เรย์ แปลว่า "รันหลังการวาดทุกครั้ง" · ถ้าข้างในไม่มีอะไรทำให้ต้องวาดใหม่ มันก็รันแค่ครั้งเดียว แล้วเงียบไป — เปลืองนิดหน่อยแต่ไม่ระเบิด
ตัวเลือกที่ 1 คือเคสในกล่องข้างบน วนแน่นอน
ตัวเลือกที่ 3 และ 4 วนเหมือนกัน และร้ายกว่าด้วยซ้ำ เพราะต่อให้ใส่ [user] หรือ [items] ไปก็ยังวนอยู่ดี — React เทียบ dependency ด้วย Object.is ซึ่งดูตัวตนของ object ไม่ได้ดูเนื้อข้างใน · object ใหม่ทุกรอบ = ไม่เท่าเดิมทุกรอบ = รันใหม่ทุกรอบ
จุดที่ต้องจำ: เจอ error "Maximum update depth exceeded" เมื่อไหร่ ให้ไปมองสองที่เท่านั้น — ข้างในมี setState ไหม และ dependency ตัวไหนเป็นของใหม่ทุกรอบ

5 · กับดัก 2 — ใส่ [] แล้วมันยังรันสองครั้ง

อันนี้คือเรื่องที่ทำให้คนเสียเวลาไล่หาสาเหตุเป็นชั่วโมง เพราะมันไม่ใช่บั๊ก แต่หน้าตาเหมือนบั๊กทุกอย่าง
ลองอันง่ายที่สุด — effect ที่ทำแค่ console.log แล้วใส่ [] ปิดท้าย:

'use client';

import { useEffect, useState } from 'react';

export default function RunsOnce() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('[RunsOnce] effect ทำงาน');
  }, []);

  return <button onClick={() => setCount(count + 1)}>กดแล้ว {count} ครั้ง</button>;
}

console จริงตอนเปิดหน้าด้วย npx next dev — ยังไม่ได้กดปุ่มเลยสักครั้ง (หน้านี้มี Clock จากหัวข้อ 6 วางอยู่ด้วย จะได้เห็น cleanup ไปพร้อมกัน · ตัด Download the React DevTools… กับ [HMR] connected ออก):

[Clock] setup: ตั้ง setInterval
[RunsOnce] effect ทำงาน
[Clock] cleanup: clearInterval 10
[Clock] setup: ตั้ง setInterval
[RunsOnce] effect ทำงาน

[RunsOnce] effect ทำงาน ขึ้นมาสองครั้ง ทั้งที่ใส่ [] ไว้แล้ว
และของ Clock ยิ่งชัด — setup → cleanup → setup ครบชุด

ทำไมถึงเป็นแบบนั้น

Next.js เปิด React Strict Mode ให้ในโหมด dev โดยไม่ต้องตั้งค่าอะไร · Strict Mode จงใจ mount → unmount → mount component ของคุณหนึ่งรอบ เพื่อตรวจว่า cleanup ของคุณเขียนถูกไหม
ถ้าเขียนถูก รันสองรอบแล้วผลจะเหมือนรันรอบเดียว (ต่อ-ตัด-ต่อ = สุดท้ายก็ต่ออยู่เส้นเดียว)
ถ้าเขียนผิด — เช่นลืม cleanup — คุณจะได้ของค้างสองชุดตั้งแต่วินาทีแรก Strict Mode ไม่ได้สร้างบั๊กให้คุณ มันทำให้บั๊กที่มีอยู่แล้วโผล่มาให้เห็นเร็วขึ้น

# build เป็น production แล้วเปิดด้วย next start
npx next build
npx next start -p 3074

เปิดหน้าเดิมบน production build คิดว่า [RunsOnce] effect ทำงาน จะขึ้นกี่ครั้ง?

ครั้งเดียว — นี่คือ console จริงจาก next start ตัดเอาเฉพาะบรรทัดของเรา:

[Clock] setup: ตั้ง setInterval
[RunsOnce] effect ทำงาน

ไม่มี cleanup แทรกกลาง ไม่มีรอบสอง · การรันสองครั้งเป็นเรื่องของโหมด dev เท่านั้น ผู้ใช้จริงบนเว็บที่ deploy แล้วไม่เจอ
แต่ห้ามใช้ข้อนี้เป็นข้ออ้างที่จะไม่เขียน cleanup — ถ้ารันสองครั้งแล้วพัง แปลว่าโค้ดคุณพังอยู่แล้ว แค่ยังไม่มีใครกดจนเจอ
(ถ้าอยากตรวจซ้ำเอง: ต้อง npx next build ให้เสร็จก่อน แล้วค่อย npx next start เพราะ next start เสิร์ฟของที่ build ไว้แล้วเท่านั้น)

6 · cleanup — ญาติของ defer ที่คุณเรียนไปแล้ว

ตอนเรียน Go คุณเจอท่านี้มาแล้วใน บทเรียน 08: เปิดของอะไรไว้ ให้เขียนคำสั่งปิดไว้ติดกันเลย จะได้ไม่ลืม

Go — defer

f, err := os.Open("data.txt")
if err != nil {
    return err
}
defer f.Close()

เปิดไฟล์แล้วสั่งปิดไว้เลย · ปิดจริงตอนออกจากฟังก์ชัน

React — return ใน effect

useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, []);

ตั้งนาฬิกาแล้วสั่งเลิกไว้เลย · เลิกจริงตอนไหน — อ่านตารางข้างล่าง

🧰 เทียบให้เห็นภาพ — เช่าห้องซ้อมดนตรี

เข้าห้องซ้อม = setup (เปิดแอร์ เสียบปลั๊ก ตั้งไมค์) · ออกจากห้อง = cleanup (ถอดปลั๊ก ปิดแอร์ คืนกุญแจ)
ถ้าคุณออกโดยไม่ถอดปลั๊ก ห้องก็ยังกินไฟอยู่ ทั้งที่ไม่มีใครอยู่ในนั้นแล้ว — นั่นคือ timer ที่ค้างอยู่หลัง component หายไปจากหน้า

ที่ไม่เหมือนกัน: defer ของ Go ทำงานครั้งเดียวจบ ตอนฟังก์ชันจบ และเวลานั้นคาดเดาได้แน่นอน — อ่านโค้ดแล้วรู้เลย
ส่วน cleanup ของ React ทำงานหลายครั้งได้ และคุณไม่ได้เป็นคนกำหนดเวลา — React เรียกมันทุกครั้งที่ dependency เปลี่ยน (ตัดก่อน ต่อใหม่) และเรียกอีกทีตอน component ออกจากหน้า
อีกอย่างที่ต่างคือ defer ผูกกับฟังก์ชันหนึ่งครั้งที่ถูกเรียก แต่ cleanup ผูกกับรอบชีวิตของ component บนหน้าจอ ซึ่งยาวกว่าและถูกขัดจังหวะได้ตลอด

เหตุการณ์cleanup รันไหมเห็นได้จากผลรันในหัวข้อไหน
dependency เปลี่ยนค่ารัน ก่อน setup รอบใหม่หัวข้อ 3 — ❌ ห้องเก่า มาก่อน ✅ ห้องใหม่เสมอ
component ถูกเอาออกจากหน้ารัน เป็นครั้งสุดท้ายหัวข้อ 6 — การทดลอง 8 timer ข้างล่าง
โหมด dev เปิดหน้าครั้งแรกรัน (Strict Mode)หัวข้อ 5 — setup → cleanup → setup
state อื่นเปลี่ยน แต่ไม่ได้อยู่ใน dependencyไม่รัน—

กับดัก 3 — ลืม cleanup แล้ว timer ค้าง

ผมทำสองอันมาวางเทียบกัน ต่างกันบรรทัดเดียว คือมี return หรือไม่มี:

✗ ลืม cleanup

useEffect(() => {
  const id = setInterval(() => {
    ticks = ticks + 1;
    console.log('[Leaky] tick!', ticks);
  }, 1000);
  // ลืม return cleanup
}, []);

✓ มี cleanup

useEffect(() => {
  const id = setInterval(() => {
    ticks = ticks + 1;
    console.log('[Tidy] tick!', ticks);
  }, 1000);
  return () => clearInterval(id);
}, []);

วิธีทดลอง: เอา component ตัวนี้ใส่-ถอดออกจากหน้าสลับกัน 8 ครั้ง แล้วปิดท้ายด้วยการถอดออก (บนหน้าไม่เหลือมันแล้ว)
จากนั้นนับว่าใน 3 วินาทีถัดมา มี tick กี่ครั้ง — ตัวเลขนี้อ่านมาจาก console จริง:

ตัวไหนอยู่บนหน้าตอนวัดไหมtick ใน 3 วินาทีแปลว่า
✗ ลืม cleanupไม่อยู่แล้ว24 ครั้งมี timer ค้างอยู่ 8 ตัว (24 ÷ 3 วินาที)
✓ มี cleanupไม่อยู่แล้ว0 ครั้งเก็บกวาดครบ ไม่เหลืออะไรเลย

ทำไมถึงได้ 8 ตัว ทั้งที่กดใส่-ถอดแค่ 8 ครั้ง (= ใส่จริง 4 ครั้ง)?
เพราะStrict Mode ในหัวข้อ 5 นั่นแหละ — ใส่หนึ่งครั้ง effect รันสองรอบ = ได้ timer สองตัว · ใส่ 4 ครั้ง = 8 ตัว ตรงเป๊ะ
กับดักสองอันนี้ทบกัน และนี่คือเหตุผลที่ Strict Mode มีอยู่: ถ้าคุณเขียน cleanup ถูก ตัวเลขจะเป็น 0 ไม่ว่าจะรันกี่รอบ

ของที่ต้องมี cleanup เกือบ 100%: setInterval · setTimeout · addEventListener · WebSocket · subscribe() ของ library ต่าง ๆ
สังเกตว่าทุกตัวคือ "ของที่สมัครไว้แล้วมันจะทำงานต่อไปเรื่อย ๆ จนกว่าจะยกเลิก"
ถ้า effect ของคุณสร้างของแบบนี้แล้วไม่มี return ให้ถือว่าผิดไว้ก่อน

7 · กับดัก 4 — โค้ดนาฬิกาในสไลด์ พังทันทีที่วางลง Next.js

สไลด์หน้า 42 ให้ตัวอย่างนาฬิกาไว้แบบนี้ (แปลงเป็นไฟล์เต็มพร้อม 'use client' แล้ว):

'use client';

import { useEffect, useState } from 'react';

export default function Clock() {
  const [now, setNow] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setNow(new Date()), 1000);
    return () => clearInterval(id);
  }, []);

  return <time>{now.toLocaleTimeString()}</time>;
}

effect เขียนถูกทุกอย่าง — มี [] · มี cleanup · npx tsc --noEmit ผ่าน · npx next build ผ่าน
แต่พอเปิดหน้าจริง Next.js เด้ง error overlay สีแดง ขึ้นมา นี่คือข้อความจริงที่ได้:

Recoverable Error
Hydration failed because the server rendered text didn't match the client.
As a result this tree will be regenerated on the client.
This can happen if a SSR-ed Client Component used:

- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes
  each time it's called.
- Date formatting in a user's locale which doesn't match the server.
...

+ Client
- Server

  <Clock>
    <time>
+     3:39:24 PM
-     3:39:23 PM

components/t74/Clock.tsx (17:10) @ Clock

อ่านสองบรรทัดล่างสุดก็จบเรื่อง — เซิร์ฟเวอร์เขียน 3:39:23 PM · เบราว์เซอร์คำนวณได้ 3:39:24 PM
สาเหตุ: 'use client' ไม่ได้แปลว่า "รันแค่บนเบราว์เซอร์" · Next.js ยัง render component นี้บนเซิร์ฟเวอร์หนึ่งรอบเพื่อทำ HTML ตั้งต้น แล้วเบราว์เซอร์ค่อยมา "รับช่วงต่อ" (คำว่า hydration)
ค่าตั้งต้นของ useState จึงถูกคำนวณสองที่ คนละเวลา — new Date() ให้คนละค่าแน่นอน

ระวัง — ในโหมด dev บั๊กตัวนี้ "งอแง" ไม่ได้โผล่ทุกครั้ง

next dev render หน้าใหม่ทุกครั้งที่มีคนขอ · error จะโผล่ก็ต่อเมื่อ วินาทีเปลี่ยนพอดีในช่วงระหว่างที่เซิร์ฟเวอร์ทำ HTML กับตอนที่เบราว์เซอร์ hydrate เสร็จ ซึ่งเป็นช่วงสั้นมาก · ผมลองเปิดซ้ำ 12 รอบ ได้ error แค่ 1 รอบ
แปลว่าถ้าคุณลองแล้วไม่เห็นอะไร ยังไม่ได้แปลว่าโค้ดถูก — ให้กดรีเฟรชรัว ๆ หลาย ๆ รอบ
ตรงข้ามกับ production: npx next build prerender หน้านี้ตั้งแต่ตอน build (ในผลของ build จะเห็น ○ (Static) อยู่หน้าชื่อ route) เวลาใน HTML จึงค้างอยู่ที่เวลาตอน build ไม่มีทางตรงกับเวลาบนเครื่องผู้ใช้เลย · ผมลอง next start 3 รอบ ได้ error ทั้ง 3 รอบ
นี่คือกับดักซ้อนกับดัก: บั๊กที่โผล่ยากตอน dev แต่โผล่ทุกครั้งของจริง

วิธีแก้ที่ผมรันแล้วได้ผล — อย่าให้ค่าตั้งต้นมาจากเวลา ให้เริ่มจาก null แล้วปล่อยให้ effect เป็นคนใส่ค่าจริงหลังหน้าวาดเสร็จ:

'use client';

import { useEffect, useState } from 'react';

export default function ClockSafe() {
  const [now, setNow] = useState<string | null>(null);

  useEffect(() => {
    const id = setInterval(() => setNow(new Date().toLocaleTimeString()), 1000);
    return () => clearInterval(id);
  }, []);

  return <time>{now ?? '--:--:--'}</time>;
}

เซิร์ฟเวอร์กับเบราว์เซอร์ตอนเริ่มต้นได้ --:--:-- เหมือนกันเป๊ะ → ไม่มีอะไรให้ mismatch
เปิดหน้าจริงสองรอบแล้ว console เหลือแค่ข้อความของ HMR กับ React DevTools ไม่มี error สักบรรทัด · npx eslint ก็ผ่านสะอาด
ราคาที่จ่ายคือวินาทีแรกจะเห็น --:--:-- ก่อนตัวเลขจะขึ้น — ยอมรับได้ และซื่อสัตย์กับความจริง เพราะเซิร์ฟเวอร์ไม่มีทางรู้เวลาบนเครื่องผู้ใช้อยู่แล้ว

บน production build อาการเดียวกันหน้าตาต่างไป — ไม่มี overlay สีแดงพร้อมคำอธิบายให้ เหลือแค่บรรทัดเดียวใน console ว่า Minified React error #418 พร้อมลิงก์ไปเปิดอ่านเอง
นี่คือเหตุผลที่ต้องไล่ error ให้หมดตั้งแต่ตอน dev — ของแบบนี้พอขึ้น production แล้วอ่านยากขึ้นมาก

ไฟล์มี 'use client' อยู่บรรทัดแรกเรียบร้อย แล้วทำไม useState(new Date()) ถึงยังทำให้เกิด hydration mismatch ได้อีก?

ตอบ ตัวเลือกที่ 1 — 'use client' แปลว่า "ชิ้นนี้ต้องส่ง JavaScript ไปให้เบราว์เซอร์ด้วย" ไม่ได้แปลว่า "ห้ามรันบนเซิร์ฟเวอร์" · Next.js ยัง render มันบนเซิร์ฟเวอร์หนึ่งรอบเพื่อทำ HTML ตั้งต้น ค่าตั้งต้นจึงถูกคำนวณสองที่ คนละเวลา
ตัวเลือกที่ 2 ผิดตรงข้ามเลย — 'use client' ต้องอยู่บนสุดก่อน import ถ้าย้ายลงไปใต้ import มันจะไม่ทำงาน แล้วคุณจะได้ build error ของหัวข้อ 2 แทน
ตัวเลือกที่ 3 มั่ว — useState รับค่าอะไรก็ได้ รวมทั้ง object · เก็บ Date ไม่ผิดกฎอะไร ที่ผิดคือค่าตั้งต้นที่คำนวณใหม่ทุกครั้ง
ตัวเลือกที่ 4 เอากับดักหัวข้อ 4 มาปนมั่ว — โค้ดนาฬิกาชุดนี้ใส่ [] ไว้ครบและมี cleanup ไม่มีการวนซ้ำเลย
จุดที่ต้องจำ: เจอคำว่า hydration เมื่อไหร่ ให้ถามตัวเองข้อเดียวว่า "ค่านี้เซิร์ฟเวอร์กับเบราว์เซอร์คำนวณแล้วได้เท่ากันไหม"

8 · ESLint ของ Next 16 ดักให้แล้ว ก่อนคุณจะทันได้รัน

ทั้งหมดที่ผ่านมา ผมรันจนเจอเอง — แต่จริง ๆ แล้ว config ที่ create-next-app แถมมาให้ จับได้ตั้งแต่ยังไม่รัน · นี่คือผลจริงของ npx eslint components/t74:

components/t74/LeakyTimer.tsx
  9:11  warning  'id' is assigned a value but never used
                 @typescript-eslint/no-unused-vars

components/t74/MissingDep.tsx
  9:6   warning  React Hook useEffect has a missing dependency: 'userId'.
                 Either include it or remove the dependency array
                 react-hooks/exhaustive-deps

components/t74/NoDeps.tsx
  8:3   warning  React Hook useEffect contains a call to 'setCount'. Without a
                 list of dependencies, this can lead to an infinite chain of
                 updates. To fix this, pass [count] as a second argument to the
                 useEffect Hook
                 react-hooks/exhaustive-deps
  10:5  error    Error: Calling setState synchronously within an effect can
                 trigger cascading renders
                 react-hooks/set-state-in-effect

✖ 4 problems (1 error, 3 warnings)

อ่านสี่อันนี้ให้ดี — มันคือกับดักทั้งหมดในบทนี้ พูดออกมาเป็นภาษาอังกฤษก่อนที่คุณจะเสียเวลาไล่หา
อันแรกน่าสนใจที่สุด: มันไม่ได้รู้เรื่อง React เลย มันแค่บอกว่า "คุณสร้างตัวแปร id ขึ้นมาแล้วไม่ได้ใช้" — ซึ่งแปลว่าคุณลืม clearInterval(id) พอดี
และสังเกตบรรทัดสุดท้าย: 1 error ปนมากับ warning ด้วย · ตัวนั้นคือกฎใหม่ในกล่องข้างล่าง

กฎใหม่ที่คุณจะเจอแน่ และมันเป็น error ไม่ใช่ warning

ใน Next 16 + React 19 มีกฎชื่อ react-hooks/set-state-in-effect ที่ห้ามเรียก setState ตรง ๆ ในตัว effect · นี่คือข้อความเต็มที่ผมได้ตอนลองเขียนนาฬิกาที่ใส่เวลาทันที ในบรรทัดแรกของ effect แล้วค่อยตั้ง setInterval ต่อ:

components/t74/ClockSync.tsx
  9:5  error  Error: Calling setState synchronously within an effect can
              trigger cascading renders
...
Calling setState synchronously within an effect body causes cascading
renders that can hurt performance, and is not recommended.
(https://react.dev/learn/you-might-not-need-an-effect).

components/t74/ClockSync.tsx:9:5
   7 |
   8 |   useEffect(() => {
>  9 |     setNow(new Date().toLocaleTimeString());
     |     ^^^^^^ Avoid calling setState() directly within an effect
  10 |     const id = setInterval(() => setNow(new Date().toLocaleTimeString()), 1000);
  11 |     return () => clearInterval(id);
  12 |   }, []);  react-hooks/set-state-in-effect

✖ 1 problem (1 error, 0 warnings)

สังเกตคำว่า synchronously — เรียก setNow ข้างใน callback ของ setInterval ไม่โดน (นั่นคือ "ของข้างนอกเปลี่ยน แล้วเราค่อยบอก React") แต่เรียกในบรรทัดแรกของ effect เลย โดน
ผมลองแล้วจริง: นาฬิกาที่ตั้งค่าเวลาทันทีในบรรทัดแรกของ effect ติด error ตัวนี้ · พอตัดบรรทัดนั้นออกเหลือแต่ setInterval ก็ผ่านสะอาด

ลิงก์ที่อยู่ในข้อความ error — "You Might Not Need an Effect" — คือคำใบ้ที่ทีม React พยายามบอกมาหลายปีแล้ว: useEffect ถูกใช้ผิดที่บ่อยกว่าใช้ถูกที่
ตารางข้างล่างคือเส้นแบ่ง ถ้าจำได้ตารางเดียวก็คุ้มทั้งบท:

สิ่งที่อยากทำใช้ useEffect?ทำไม
ตั้ง setInterval / setTimeout✅ ใช่เป็นของนอก React ต้องมีคนไปสั่งเปิดและสั่งปิด
เปิด WebSocket / subscribe✅ ใช่เหตุผลเดียวกัน และเป็นตัวอย่างในสไลด์
addEventListener ที่ window✅ ใช่DOM คือโลกนอก React
คำนวณยอดรวมจาก state ที่มีอยู่แล้ว❌ ไม่คำนวณตอน render ไปเลย ไม่ต้องเก็บเป็น state ซ้ำ
อัปเดต state ตอนผู้ใช้กดปุ่ม❌ ไม่ทำใน event handler ของปุ่มนั้น ตรงกว่าและอ่านง่ายกว่า
โหลดข้อมูลมาแสดงบนหน้า⚠️ ได้ แต่ไม่ใช่ทางแรกใน App Router ให้ดึงใน Server Component แทน จะไม่ต้องส่ง JS ไปเบราว์เซอร์เลย
🗣 ศัพท์ที่เพิ่งเรียนไปในหัวข้อ 5–8
cleanup function
ฟังก์ชันที่ return ออกมาจาก effect · React เก็บไว้แล้วเรียกให้ก่อน setup รอบถัดไป และตอน component ออกจากหน้า · ถาม senior ได้ว่า "effect นี้ต้องมี cleanup ไหม" ทุกครั้งที่ effect สร้างของที่ทำงานต่อเนื่อง
mount · unmount
"ถูกใส่เข้าหน้า" กับ "ถูกเอาออกจากหน้า" · Strict Mode ในโหมด dev จงใจทำ mount → unmount → mount เพื่อทดสอบ cleanup ของคุณ · คำนี้ใช้คุยกับ senior ได้ตรง ๆ ว่า "มันรันตอน mount หรือทุก render"
hydration · hydration mismatch
ขั้นตอนที่เบราว์เซอร์เอา JavaScript มา "รับช่วงต่อ" HTML ที่เซิร์ฟเวอร์ส่งมา · ถ้าสองฝั่งคำนวณได้ไม่ตรงกันเรียกว่า mismatch · พิมพ์บอก AI ได้ว่า "this component causes a hydration mismatch because of Date — fix it" คำว่า hydration ทำให้มันตอบตรงจุดกว่าบอกว่า "หน้าเว็บขึ้น error แดง"

9 · สรุปเป็นภาพเดียว

เปิดหน้าครั้งแรก
   └─ วาดจอเสร็จ ──> setup รัน          (dev: setup → cleanup → setup)

dependency เปลี่ยน
   └─ วาดจอเสร็จ ──> cleanup ของรอบเก่า ──> setup รอบใหม่

component ออกจากหน้า
   └────────────> cleanup รันครั้งสุดท้าย   (ไม่มี setup ตามมาแล้ว)

ไม่ใส่ dependency array
   └─ วาดจอเสร็จ ──> setup รัน ──> ถ้ามี setState ──> วาดใหม่ ──> วนกลับไปข้อบน ♾️

component หนึ่งมี useEffect(fn, [userId]) · ผู้ใช้เปลี่ยน userId จาก "a" เป็น "b" ลำดับที่เกิดขึ้นคืออะไร?

ตอบ ตัวเลือกที่ 3 — และนี่ไม่ใช่การเดา ผมรันแล้วดู console จริงในหัวข้อ 3: ทุกครั้งที่ย้ายห้อง ❌ ตัดการเชื่อมต่อห้อง random ขึ้นมาก่อน ✅ เชื่อมต่อห้อง support เสมอ ไม่มีสลับ
ตัวเลือกที่ 1 กลับลำดับ ซึ่งถ้าเป็นแบบนั้นจริงคุณจะได้ connection สองเส้นซ้อนกันชั่วขณะ
ตัวเลือกที่ 2 คือสิ่งที่สไลด์หน้า 41 เขียนไว้ (บอกว่า cleanup รันตอน unmount) — ไม่ครบ ผลรันแย้งไปแล้ว
ตัวเลือกที่ 4 สับสนระหว่าง [userId] กับ [] ว่าง · [] ว่างต่างหากที่แปลว่าครั้งเดียว
จุดที่ต้องจำ: ให้อ่าน dependency array ว่า "ถ้าค่าพวกนี้เปลี่ยน ให้เลิกของเก่าแล้วเริ่มใหม่" ไม่ใช่แค่ "ให้รันอีกที"

คุณเปิดหน้าใน npx next dev แล้วเห็น log ของ effect ขึ้นสองครั้ง ควรทำอะไรต่อ?

ตอบ ตัวเลือกที่ 1 — การรันสองครั้งใน dev คือเครื่องมือตรวจ ไม่ใช่บั๊ก ผมพิสูจน์แล้วในหัวข้อ 5 ว่า production build รันครั้งเดียว
ตัวเลือกที่ 2 เข้าใจผิดคนละเรื่อง — เอา 'use client' ออกจะได้ build error ทันที ตามที่เห็นในหัวข้อ 2 ไม่ใช่ effect รันครั้งเดียว
ตัวเลือกที่ 3 คือการเอาเทปมาปิดไฟเตือนบนแผงหน้าปัด — อาการหาย ปัญหาอยู่ · แถมท่านี้ยังพังเวลา component ถูกเปิดพร้อมกันหลายตัว เพราะตัวแปรนอก component มีก้อนเดียวใช้ร่วมกันทุกตัว
ตัวเลือกที่ 4 ปิดได้จริงทางเทคนิค แต่แลกด้วยการปิดตาข่ายที่จะเตือนคุณเรื่อง cleanup ทั้งโปรเจกต์
จุดที่ต้องจำ: "รันสองครั้งแล้วพัง" ไม่ใช่ปัญหาของ Strict Mode แต่แปลว่า effect ของคุณไม่ทนต่อการรันซ้ำ ซึ่งวันหนึ่งผู้ใช้จริงจะทำให้มันรันซ้ำจนได้

🛠 ภารกิจ — พังให้เห็นกับตาก่อน แล้วค่อยแก้

ทำในโปรเจกต์ Next.js ของคุณเอง (ถ้ายังไม่มี: npx create-next-app@latest) · ห้ามข้ามข้อ 2 เพราะข้อ 2 คือทั้งหมดของบทนี้:

  1. สร้าง components/Ticker.tsx ที่มี 'use client' บรรทัดแรก · ข้างในมี state seconds เริ่มที่ 0 และ effect ที่ตั้ง setInterval ให้บวกทีละ 1 ทุกวินาที · ตั้งใจไม่ใส่ cleanup แล้วเอาไปแสดงในหน้าที่มีปุ่มซ่อน/แสดงมันได้
  2. กด "แสดง แล้วซ่อน" สลับกัน 5 รอบ (= ใส่มันเข้าหน้า 5 ครั้ง) แล้วปิดท้ายด้วยการซ่อน · เปิด console ไว้ตลอด แล้วจดว่าตอนซ่อนอยู่ ยังมี log ไหลอยู่ไหม และไหลถี่ขึ้นแค่ไหนเทียบกับตอนแรก
  3. เติม return () => clearInterval(id); แล้วทำข้อ 2 ซ้ำ · เทียบตัวเลข
  4. รัน npx eslint components/Ticker.tsx ทั้งก่อนและหลังแก้ · ดูว่ามันเตือนอะไรตอนที่ยังไม่มี cleanup
  5. โบนัส: เปลี่ยนค่าตั้งต้นเป็น useState(new Date()) แล้วเอาไปแสดงบนหน้า · ดูว่าเจอ hydration error แบบหัวข้อ 7 ไหม แล้วแก้ให้หายด้วยวิธีในกล่องสีเขียว

ข้อ 2 — log ต้องยังไหลอยู่ทั้งที่ซ่อนไปแล้ว และไหลถี่ขึ้นเรื่อย ๆ ทุกครั้งที่กดแสดงใหม่ · ในการทดลองของผม กด 8 ครั้งได้ timer ค้าง 8 ตัว (24 tick ใน 3 วินาที)
ถ้าของคุณได้ 10 ตัวจาก 5 ครั้ง ก็ถูกแล้ว — Strict Mode คูณสองให้

ข้อ 3 — ต้องได้ 0 เป๊ะ ๆ ไม่ใช่ "น้อยลง" · ถ้ายังเหลือแม้แต่ตัวเดียว แปลว่า cleanup ยังไม่ครบทุกเส้นทาง

ข้อ 4 — ตอนยังไม่มี cleanup ควรได้อย่างน้อย 'id' is assigned a value but never used · ถ้า effect ของคุณเรียก setState ตรง ๆ ในบรรทัดแรกด้วย จะได้ react-hooks/set-state-in-effect เป็น error เพิ่มมาอีกอัน — อ่านลิงก์ You Might Not Need an Effect ที่มันแปะมาให้ด้วย คุ้มมาก

💬 ติดตรงไหนถามได้ — ถ้ารันตามในบทแล้วได้ผลไม่ตรงกับที่เขียนไว้ ให้ส่งมาทั้งไฟล์ component เต็ม ๆ · คำสั่งที่ใช้รัน · และข้อความใน console ทั้งก้อน
เรื่อง useEffect เกือบทุกคำถามตอบได้จากสามอย่างนี้ เพราะคำตอบมักอยู่ที่ dependency array เขียนว่าอะไร กับ มี return หรือเปล่า
และคำถามที่คุ้มที่สุดสำหรับบทนี้คือ "อันนี้ต้องใช้ useEffect จริงไหม" — ถามก่อนเขียนทุกครั้ง คุณจะเขียน useEffect น้อยลงมาก และนั่นคือเรื่องดี

อ้างอิง