🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 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
มีสองเรื่องซ่อนอยู่ในผลนี้ และทั้งสองเรื่องคือหัวข้อถัดไป:
- สามบรรทัดแรก เกิดตอนเปิดหน้าเฉย ๆ ยังไม่ได้กดอะไรเลย — ต่อ ตัด แล้วต่อใหม่ · นั่นคือ Strict Mode ในโหมด dev (หัวข้อ 5)
- ทุกครั้งที่ย้ายห้อง มี ❌ ของห้องเก่ามาก่อน ✅ ของห้องใหม่เสมอ — แปลว่า cleanup ไม่ได้รันแค่ตอนออกจากหน้า อย่างที่สไลด์เขียน แต่รันก่อนการรันรอบใหม่ทุกรอบด้วย (หัวข้อ 6)
สไลด์หน้า 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 ไปเบราว์เซอร์เลย |
- 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 คือทั้งหมดของบทนี้:
-
สร้าง
components/Ticker.tsxที่มี'use client'บรรทัดแรก · ข้างในมี statesecondsเริ่มที่0และ effect ที่ตั้งsetIntervalให้บวกทีละ 1 ทุกวินาที · ตั้งใจไม่ใส่ cleanup แล้วเอาไปแสดงในหน้าที่มีปุ่มซ่อน/แสดงมันได้ - กด "แสดง แล้วซ่อน" สลับกัน 5 รอบ (= ใส่มันเข้าหน้า 5 ครั้ง) แล้วปิดท้ายด้วยการซ่อน · เปิด console ไว้ตลอด แล้วจดว่าตอนซ่อนอยู่ ยังมี log ไหลอยู่ไหม และไหลถี่ขึ้นแค่ไหนเทียบกับตอนแรก
-
เติม
return () => clearInterval(id);แล้วทำข้อ 2 ซ้ำ · เทียบตัวเลข -
รัน
npx eslint components/Ticker.tsxทั้งก่อนและหลังแก้ · ดูว่ามันเตือนอะไรตอนที่ยังไม่มี cleanup -
โบนัส: เปลี่ยนค่าตั้งต้นเป็น
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 น้อยลงมาก และนั่นคือเรื่องดี
อ้างอิง
- React —
useEffect— อ่านหัวข้อ Parameters ให้จบ โดยเฉพาะประโยคที่บอกว่า React จะเทียบ dependency ด้วยObject.is· นี่คือคำอธิบายว่าทำไมใส่ object หรือ array เป็น dependency แล้ว effect ถึงรันทุกรอบ - React — Synchronizing with Effects — อ่านหัวข้อ How to handle the Effect firing twice in development? ซึ่งตอบกับดักในหัวข้อ 5 ของบทนี้ตรง ๆ · ทีม React เขียนไว้ชัดว่าวิธีแก้ที่ถูกคือ "ทำให้ effect ทนการรันซ้ำ" ไม่ใช่ "หาทางไม่ให้มันรันซ้ำ"
- React — You Might Not Need an Effect —
หน้าเดียวกับที่ ESLint แปะลิงก์มาให้ในข้อความ error ·
อ่านหัวข้อ Updating state based on props or state ก่อนเป็นอันแรก
เพราะเป็นเคสที่มือใหม่เขียน
useEffectผิดบ่อยที่สุด - React — Connecting to an external system —
ตัวอย่าง
ChatRoomตัวเดียวกับในสไลด์หน้า 41 · ดูปุ่ม Show more ที่มีตัวอย่างครบทั้ง connection · event listener · animation แล้วสังเกตว่าทุกตัวมีreturnไม่มียกเว้น - Next.js —
'use client'— หน้าเดียวกับที่ลิงก์อยู่ใน build error ในหัวข้อ 2 · อ่านหัวข้อ How "use client" works ให้เห็นว่าคำสั่งนี้แบ่งโปรเจกต์เป็นสองฝั่งยังไง และทำไม Client Component ถึงยังถูก render บนเซิร์ฟเวอร์อยู่ดี (คำตอบของกับดักในหัวข้อ 7) - Next.js — Text content does not match server-rendered HTML —
หน้าเดียวกับที่ error overlay ลิงก์มาให้ ·
อ่านตาราง Common causes แล้วจะเห็นว่า
DateกับMath.random()อยู่บรรทัดต้น ๆ · ท้ายหน้ามีท่าsuppressHydrationWarningให้ด้วย — อ่านให้รู้ว่ามีอยู่ แต่อย่าเพิ่งใช้ก่อนที่จะแก้ที่ต้นเหตุ - React —
<StrictMode>— อ่านหัวข้อ Fixing bugs found by re-running Effects in development ซึ่งอธิบายว่าการรันซ้ำเป็นการทดสอบอะไร · Next.js เปิดตัวนี้ให้ในโหมด dev อยู่แล้ว คุณไม่ต้องไปใส่เอง