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

useContext — ส่งค่าข้ามหลายชั้นโดยไม่ต้องส่งต่อทีละชั้น

~25 นาที · ควรผ่าน บทเรียน 75 (useMemo / useCallback) มาก่อน เพราะกับดักข้อที่ 2 ของบทนี้ต่อยอดจากบทนั้นตรง ๆ

บทนี้เริ่มจากปัญหาก่อน ไม่ใช่เครื่องมือก่อน
เราจะเขียนโค้ดที่ส่งค่าตัวเดียวผ่าน component 3 ชั้นให้ดูจริง ๆ ก่อน ให้เห็นว่ามันน่ารำคาญตรงไหน แล้วค่อยเอา useContext เข้ามาแทน
แล้วจะลงกับดัก 4 อันที่มือใหม่เจอกันหมด — ลืมครอบ Provider · ใส่ value เป็น object ก้อนใหม่ทุกรอบจนลูกวาดใหม่ทั้งกอง · ลืม 'use client' ที่ไฟล์ context · และการเข้าใจผิดว่า context เอาไปแทน state management ได้ทุกกรณี
error ทุกก้อนในบทนี้ก็อปมาจากหน้าจอจริง ไม่ได้พิมพ์เอง

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

next        16.3.4        # จาก npx next --version
react       19.2.8        # จาก package.json ของโปรเจกต์
react-dom   19.2.8
typescript  5.9.3         # จาก npx tsc --version
node        v24.14.0      # จาก node --version

โปรเจกต์ทดสอบสร้างด้วย npx create-next-app@latest --ts --tailwind --eslint --app --no-src-dir (App Router · Turbopack) โค้ดทุกไฟล์อยู่ใต้ app/t76/ และ components/t76/
ผลลัพธ์ที่โชว์มาจาก 3 ทาง: npx tsc --noEmit · npx next build · และ log ของ npx next dev ตอน curl เข้าไปที่หน้าจริง
ส่วนตัวเลข "วาดใหม่กี่รอบ" ในหัวข้อ 6 มาจาก console.log ในเบราว์เซอร์จริง กดปุ่มจริง

1 · ปัญหาก่อน — ส่ง prop ผ่าน 3 ชั้นให้ดูของจริง

สมมติหน้าเว็บมีข้อมูลผู้ใช้อยู่บนสุด แล้วคนที่ต้องใช้จริง ๆ คือป้ายชื่อเล็ก ๆ ที่อยู่ลึกสุด
โครงเป็นแบบนี้:

DrillingPage ← user อยู่ตรงนี้ └─ Sidebar ← ไม่ได้ใช้ user เลย แต่ต้องรับแล้วส่งต่อ └─ Menu ← ไม่ได้ใช้ user เลย แต่ต้องรับแล้วส่งต่อ └─ UserBadge ← คนที่ใช้จริง

ไฟล์จริงที่ผมรัน — app/t76/drilling/page.tsx (เป็น Server Component ล้วน ไม่มี hook เลย ไม่ต้องใส่ 'use client'):

type User = { name: string; role: string };

function UserBadge({ user }: { user: User }) {
  return (
    <span>
      {user.name} ({user.role})
    </span>
  );
}

function Menu({ user }: { user: User }) {          // ไม่ได้ใช้ user เลย
  return (
    <nav>
      <UserBadge user={user} />
    </nav>
  );
}

function Sidebar({ user }: { user: User }) {       // ก็ไม่ได้ใช้ user เลย
  return (
    <aside>
      <Menu user={user} />
    </aside>
  );
}

export default function DrillingPage() {
  const user: User = { name: 'สมชาย', role: 'admin' };
  return (
    <main>
      <h1>prop drilling</h1>
      <Sidebar user={user} />
    </main>
  );
}

อันนี้ทำงานได้จริง ไม่ได้พัง — ผมรัน dev server แล้ว curl เข้าไปดู:

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:376/t76/drilling
200

# ใน log ของ next dev
 GET /t76/drilling 200 in 79ms (next.js: 67ms, application-code: 12ms)

ปัญหาไม่ใช่ "มันพัง" — ปัญหาคือ Sidebar กับ Menu ถูกบังคับให้รู้จัก user ทั้งที่ไม่ได้ใช้
ลองนึกว่าวันหนึ่งเราอยากส่ง theme ลงไปด้วย ต้องไปแก้กี่ไฟล์? — แก้ทั้ง 4 ไฟล์ ทั้งที่คนใช้จริงมีคนเดียว
แล้วถ้าโครงลึก 6 ชั้น มี 12 component ที่แค่ "ส่งผ่าน"? นั่นแหละที่เรียกว่า prop drilling

ข้อเสียที่แท้จริงของโค้ดข้างบนคืออะไร

ตอบตัวเลือกที่ 3 · ตัวเลือกที่ 1 ผิดเพราะการส่ง prop ต่อกันไม่ได้ทำให้ช้าจนวัดได้ มันแค่เขียนแล้วรำคาญ และแก้ทีหลังยาก · ตัวเลือกที่ 2 กับ 4 ไม่จริงเลย — โค้ดข้างบนรันผ่าน HTTP 200 และ npx tsc --noEmit ก็ผ่านสะอาด ไม่มีใครเตือนอะไรทั้งนั้น
ประเด็นคือ compiler ไม่เตือนเรื่องนี้ให้ เราต้องเห็นเอง

2 · useContext แก้ปัญหานี้ยังไง

แนวคิดคือ แทนที่จะส่งค่าลงไปทีละชั้น เราวางค่าไว้ที่ชั้นบน แล้วให้คนข้างล่างเอื้อมขึ้นมาหยิบเอง
ทำ 3 ขั้น:

ขั้นเขียนว่าทำอะไร
1 · สร้างช่อง createContext(ค่าเริ่มต้น) สร้าง "ช่องทาง" ขึ้นมาหนึ่งช่อง · ค่าที่ใส่ตรงนี้จะถูกใช้ก็ต่อเมื่อไม่มี Provider ครอบอยู่
2 · ใส่ค่าลงช่อง <MyContext value={…}> ครอบ subtree ที่อยากให้เห็นค่านี้ · ทุกอย่างที่อยู่ข้างในเอื้อมมาหยิบได้หมด
3 · หยิบค่าออกมา useContext(MyContext) เรียกจาก component ไหนก็ได้ที่อยู่ใต้ Provider · ไม่ต้องมี prop ใด ๆ

จุดที่คนมักเข้าใจผิดตั้งแต่แรก: useContext ไม่ได้มองหา "Provider ที่ไหนก็ได้ในโปรแกรม" มันไต่ขึ้นไปตามต้นไม้ของ component จากจุดที่เรียก แล้วหยุดที่ Provider ตัวที่ใกล้ที่สุดที่อยู่เหนือมัน
สไลด์หน้า 55 เขียนเรื่องนี้ไว้ตรง ๆ ว่า "Always look for the closest provider above the component that calls" และเตือนต่อว่า "Does not consider providers in the component that calls" — แปลว่า ถ้าคุณ return Provider ออกไปจาก component เดียวกับที่เรียก useContext ตัวมันเองจะไม่เห็นค่านั้น เพราะ Provider ตัวนั้นอยู่ "ใต้" มัน ไม่ได้อยู่ "เหนือ" มัน

เทียบกับของจริง

createContext เหมือนติดตั้งท่อน้ำเส้นหนึ่งในตึก · <Provider value={…}> เหมือนเปิดวาล์วปล่อยน้ำเข้าท่อที่ชั้นหนึ่ง · useContext เหมือนก๊อกน้ำในห้อง เปิดก๊อกแล้วน้ำมาเลย ไม่ต้องให้คนชั้น 2 กับชั้น 3 หิ้วถังส่งต่อกันลงมา

ที่ไม่เหมือนกัน: น้ำในตึกจริงไหลลงมาจากถังบนดาดฟ้าเสมอ ไม่ว่าห้องคุณจะอยู่ชั้นไหน แต่ context ไม่ใช่ — มันหยิบจากวาล์วตัวที่ใกล้ที่สุดที่อยู่เหนือห้องคุณ ถ้าชั้น 5 มีวาล์วของตัวเองอีกอัน ห้องชั้น 6 จะได้น้ำจากวาล์วชั้น 5 ไม่ใช่ของชั้น 1
และที่ต่างชัดกว่านั้น: ถ้าไม่มีวาล์วเลยสักตัว ก๊อกน้ำจริงจะแค่ไม่มีน้ำไหลเฉย ๆ แต่ useContext จะคืนค่าเริ่มต้นที่ใส่ไว้ตอน createContext ซึ่ง "ดูเหมือนใช้ได้" — และนั่นคือกับดักที่อันตรายที่สุดของบทนี้ (หัวข้อ 5)

3 · เขียน context ของตัวเองแบบที่ใช้จริงในโปรเจกต์

สไลด์หน้า 54 แสดงท่ามาตรฐานไว้แล้ว: รวม context · Provider · และ hook สำหรับหยิบค่า ไว้ในไฟล์เดียว แล้วให้ที่อื่น import ไปใช้
นี่คือไฟล์จริงที่ผมรัน components/t76/theme.tsx — สังเกตบรรทัดแรก 'use client' เดี๋ยวหัวข้อ 7 จะพิสูจน์ให้ดูว่าถ้าไม่ใส่จะเจออะไร:

'use client';

import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';

export type Theme = { mode: 'light' | 'dark'; accent: string };

type ThemeBox = { theme: Theme; toggle: () => void };

// ค่าเริ่มต้นเป็น undefined โดยตั้งใจ — เพื่อให้ "ลืมครอบ Provider" กลายเป็น error ที่อ่านออก
const ThemeContext = createContext<ThemeBox | undefined>(undefined);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>({ mode: 'dark', accent: '#c8b89a' });

  // ห่อด้วย useMemo — เหตุผลอยู่ในหัวข้อ 6 และมีผลรันจริงยืนยัน
  const value = useMemo<ThemeBox>(
    () => ({
      theme,
      toggle: () =>
        setTheme((t) => ({ ...t, mode: t.mode === 'dark' ? 'light' : 'dark' })),
    }),
    [theme],
  );

  return <ThemeContext value={value}>{children}</ThemeContext>;
}

export function useTheme(): ThemeBox {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme ต้องเรียกใต้ <ThemeProvider> เท่านั้น');
  return ctx;
}

คนที่ใช้ค่าไม่ต้องรู้อะไรเลยนอกจากชื่อ hook — components/t76/badge.tsx:

'use client';

import { useTheme } from './theme';

export function UserBadge() {
  const { theme, toggle } = useTheme();      // ไม่มี prop สักตัว
  return (
    <span>
      โหมดตอนนี้: {theme.mode} / {theme.accent}{' '}
      <button onClick={toggle}>สลับ</button>
    </span>
  );
}

แล้วชั้นกลาง — app/t76/page.tsx — สะอาดขึ้นทันที เพราะ Sidebar กับ Menu ไม่ต้องรับ prop อะไรอีกแล้ว:

ก่อน — ส่งต่อทีละชั้น

function Sidebar({ user }: { user: User }) {
  return <aside><Menu user={user} /></aside>;
}

function Menu({ user }: { user: User }) {
  return <nav><UserBadge user={user} /></nav>;
}

เพิ่มค่าใหม่หนึ่งตัว = แก้ทุกชั้นที่ค่าเดินผ่าน

หลัง — ครอบ Provider ชั้นเดียว

function Sidebar() {
  return <aside><Menu /></aside>;
}

function Menu() {
  return <nav><UserBadge /></nav>;
}

เพิ่มค่าใหม่ = แก้แค่ไฟล์ context กับคนที่ใช้จริง

import { ThemeProvider } from '@/components/t76/theme';
import { UserBadge } from '@/components/t76/badge';

function Menu()    { return <nav><UserBadge /></nav>; }
function Sidebar() { return <aside><Menu /></aside>; }

export default function T76Page() {
  return (
    <ThemeProvider>
      <main>
        <h1>useContext</h1>
        <Sidebar />
      </main>
    </ThemeProvider>
  );
}

ผลรันจริงของหน้านี้ — ผ่านทั้ง type check และ build:

$ npx tsc --noEmit
# ไม่มี output = ผ่าน (exit code 0)

$ npx next build
  ▲ Next.js 16.3.4 (Turbopack)
✓ Running next.config.ts took 10ms

  Creating an optimized production build ...
✓ Compiled successfully in 3.0s
  Running TypeScript ...
  Finished TypeScript in 775ms ...
  Collecting page data using 9 workers ...
✓ Generating static pages using 9 workers (8/8) in 83ms
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /t76
├ ○ /t76/both-forms
├ ○ /t76/drilling
├ ƒ /t76/no-guard
├ ƒ /t76/no-provider
├ ○ /t76/rerender
└ ƒ /t76/slide-style

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

/t76/both-forms คือหน้าทดลองของหัวข้อ 4 · ส่วนสามหน้าที่ขึ้น ƒ คือหน้ากับดักของหัวข้อ 5 ที่ผมตั้งใจใส่ export const dynamic = 'force-dynamic' ไว้
ถ้าไม่ใส่ Next.js จะพยายาม prerender หน้าที่ตั้งใจให้ throw ตั้งแต่ตอน build แล้ว next build จะพังทั้งโปรเจกต์ ทั้งที่หน้าอื่นเขียนถูกหมด

ติดป้ายศัพท์ — เอาไปคุยกับ senior หรือถาม AI ได้
prop drilling
อาการที่ต้องส่ง prop ผ่าน component หลายชั้นทั้งที่ชั้นกลางไม่ได้ใช้ · เป็นคำที่ใช้เวลาอธิบายว่า "ทำไมถึงต้องเอา context เข้ามา"
Provider
component ที่ครอบ subtree แล้วกำหนดว่าค่าของ context นี้ในบริเวณนั้นคืออะไร · ใน React 19 เขียนได้สองแบบ (หัวข้อ 4)
consumer
component ที่เรียก useContext เพื่อหยิบค่า · คำนี้สำคัญตอนพูดเรื่อง re-render เพราะ "ของที่วาดใหม่เมื่อ context เปลี่ยน" คือ consumer ทุกตัว ไม่ใช่ทุก component
custom hook
ฟังก์ชันที่ขึ้นต้นด้วย use และเรียก hook ตัวอื่นข้างใน — useTheme ในบทนี้คือตัวอย่าง · บทหน้า (77) ว่าด้วยเรื่องนี้เต็ม ๆ

4 · Provider มีสองหน้าตา และสไลด์ใช้คนละแบบกันเอง

สไลด์หน้า 53 เขียน <ThemeContext value="dark"> (ไม่มี .Provider) แต่หน้า 54 เขียน <ThemeContext.Provider value={{ ...theme }}>
ไม่ใช่ว่าหน้าใดหน้าหนึ่งพิมพ์ผิด — ทั้งสองแบบใช้ได้จริงใน React 19 แบบสั้นเป็นของใหม่ที่ React 19 เพิ่มเข้ามา ส่วน .Provider เป็นของเดิม

ผมเขียนทั้งสองแบบไว้ในหน้าเดียวกันแล้วรันจริง:

const Ctx = createContext('default');

<Ctx.Provider value="แบบเก่า .Provider">
  <Read tag="old" />
</Ctx.Provider>

<Ctx value="แบบใหม่ React 19">
  <Read tag="new" />
</Ctx>

ผลที่ได้จาก HTML ที่ server ส่งกลับมาจริง:

$ curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://localhost:376/t76/both-forms
HTTP 200

# ดึงเนื้อใน <p> ออกมา
'old: แบบเก่า .Provider'
'new: แบบใหม่ React 19'

ทั้งคู่ทำงาน และใน react 19.2.8 ตัวนี้ผมไม่เห็น warning ขึ้นในคอนโซลเบราว์เซอร์เลยสักอัน
ดังนั้นโค้ดเก่าที่ใช้ .Provider ยังใช้ได้อยู่ ไม่ต้องรีบไล่แก้
แต่ถ้าเขียนใหม่ ผมแนะนำแบบสั้น <Ctx value={…}> เพราะสั้นกว่าและเป็นทิศทางที่ React เดินไป

const Ctx = createContext('A');

function Leaf() { return <p>{useContext(Ctx)}</p>; }

function App() {
  return (
    <Ctx value="B">
      <Ctx value="C">
        <Leaf />
      </Ctx>
    </Ctx>
  );
}

หน้าจอจะขึ้นตัวอักษรอะไร — A, B หรือ C?

ได้ C เพราะ useContext หยุดที่ Provider ตัวที่ใกล้ที่สุดที่อยู่เหนือ Leaf ซึ่งคือตัวที่ใส่ "C" · ตัว "B" อยู่สูงกว่าจึงถูก "บัง" · ส่วน "A" เป็นค่าเริ่มต้นตอน createContext จะถูกใช้ก็ต่อเมื่อไม่มี Provider เลย
ประโยชน์จริงของการซ้อน Provider: ทั้งเว็บใช้ธีมสว่าง แต่กล่อง preview อันเดียวอยากให้เป็นธีมมืด — ก็แค่ครอบ Provider อีกชั้นตรงนั้น ไม่ต้องแก้อะไรข้างนอก

5 · กับดัก 1 — ลืมครอบ Provider (นี่คืออันที่เจอบ่อยที่สุด)

ผมทำหน้า app/t76/no-provider/page.tsx ที่เรียก <UserBadge /> ตรง ๆ โดยไม่ครอบ <ThemeProvider> แล้ว curl เข้าไป — ได้ HTTP 500 และ log ของ next dev ขึ้นแบบนี้ (ก็อปมาทั้งดุ้น):

⨯ Error: useTheme ต้องเรียกใต้ <ThemeProvider> เท่านั้น
    at useTheme (components/t76/theme.tsx:30:19)
    at UserBadge (components/t76/badge.tsx:6:37)
  28 | export function useTheme(): ThemeBox {
  29 |   const ctx = useContext(ThemeContext);
> 30 |   if (!ctx) throw new Error('useTheme ต้องเรียกใต้ <ThemeProvider> เท่านั้น');
     |                   ^
  31 |   return ctx;
  32 | }
  33 | {
  digest: '914102247'
}
 GET /t76/no-provider 500 in 143ms (next.js: 114ms, application-code: 28ms)

เลข 30 คือบรรทัดใน theme.tsx ที่โชว์ไว้ในหัวข้อ 3 นับทั้งไฟล์รวมคอมเมนต์ — ถ้าคุณลบคอมเมนต์สองบรรทัดนั้นออก เลขจะเลื่อนเป็น 28 แต่ยังชี้ที่บรรทัด throw เหมือนเดิม
เวลาอ่าน error ให้ดูที่บรรทัดที่มีลูกศร > ไม่ใช่จำเลขบรรทัด

error นี้อ่านออกเพราะเราเขียน guard ไว้เอง ทีนี้ลองดูว่าถ้าไม่เขียน guard จะเป็นยังไง — ผมทำอีกไฟล์ที่หยิบค่ามาใช้ตรง ๆ:

export const RawThemeContext = createContext<Theme | undefined>(undefined);

export function RawBadge() {
  const { mode } = useContext(RawThemeContext)!;   // ! คือบอก TS ว่า "เชื่อฉัน ไม่ undefined หรอก"
  return <span>โหมด: {mode}</span>;
}

npx tsc --noEmit ผ่านฉลุย เพราะเราใส่ ! ปิดปาก compiler ไว้ · แล้วพอรันจริง:

⨯ TypeError: Cannot destructure property 'mode' of '{imported module ...
   .../vendored/ssr/react.js}.useContext(...)' as it is undefined.
    at RawBadge (components/t76/theme-raw.tsx:10:11)
   8 |
   9 | export function RawBadge() {
> 10 |   const { mode } = useContext(RawThemeContext)!;
     |           ^
  11 |   return <span>โหมด: {mode}</span>;
  12 | }
 GET /t76/no-guard 500 in 121ms (next.js: 82ms, application-code: 39ms)

พังเหมือนกัน แต่ข้อความไม่ได้บอกว่า "ลืมครอบ Provider" เลยสักคำ — ต้องอ่านออกเองว่า useContext(...) คืน undefined แปลว่าอะไร
นี่คือเหตุผลที่ท่ามาตรฐานคือเขียน hook ครอบพร้อม guard เสมอ อย่าให้ที่อื่นเรียก useContext ตรง ๆ

จุดที่สไลด์หน้า 54 พลาด และผมรันพิสูจน์แล้ว

โค้ดในสไลด์หน้า 54 เขียนไว้แบบนี้ (คัดมาเฉพาะบรรทัดที่เกี่ยวข้อง):

const ThemeContext = createContext("light");          // ค่าเริ่มต้นเป็น string

export const ThemeProvider = () => {                    // ไม่ได้รับ children
  const [theme, setTheme] = useState({ mode: "dark", palette: { primary: [], secondary: [] } });

  return (
    <ThemeContext.Provider value={{ ...theme }}>
      {children}                                       // แต่ใช้ children
    </ThemeContext.Provider>
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
};

ผมก็อปโค้ดนี้มาทั้งดุ้นแล้วรัน npx tsc --noEmit ได้ error 2 อัน:

slide54/theme.tsx(14,28): error TS2322: Type '{ mode: string; palette: { primary: never[]; secondary: never[]; }; }'
                          is not assignable to type 'string'.
slide54/theme.tsx(15,8):  error TS2304: Cannot find name 'children'.

⚠️ สไลด์หน้า 54 มีปัญหา 3 อย่าง — สองอันแรก compiler จับได้ อันที่สามไม่มีใครจับให้

1. TS2304: Cannot find name 'children' — ThemeProvider = () => { ไม่ได้รับ parameter อะไรเลย แล้วไปใช้ {children} · แก้โดยเขียนเป็น ({ children }: { children: ReactNode }) => {

2. TS2322 — createContext("light") ทำให้ TypeScript สรุปว่าช่องนี้เก็บ string แต่ Provider ส่ง object ลงไป · แก้โดยประกาศ type ให้ชัด เช่น createContext<ThemeBox | undefined>(undefined)

3. อันตรายที่สุด: guard if (!context) ในสไลด์ไม่มีวันทำงาน — เพราะค่าเริ่มต้นคือ "light" ซึ่งเป็น string ที่ไม่ว่าง แปลว่า truthy ต่อให้ลืมครอบ Provider จริง ๆ !context ก็เป็น false ตลอด ไม่มีการโยน error · ดูผลรันข้างล่าง

ผมทำหน้า app/t76/slide-style/page.tsx ที่ใช้ createContext('light') แบบสไลด์ แล้วเรียกใช้ โดยไม่ครอบ Provider เลย — เดาก่อนว่าจะได้อะไร:

const ThemeContext = createContext('light');

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used within ThemeProvider');
  return context;
};

// หน้าเว็บเรียก <SlideBadge /> ที่ใช้ useTheme() โดยไม่มี Provider ครอบเลย

จะได้ HTTP 500 พร้อม error "must be used within ThemeProvider" หรือได้อย่างอื่น?

ได้ HTTP 200 หน้าขึ้นปกติ ไม่มี error สักตัว — ผลรันจริง:

$ curl -s -o /tmp/ss.html -w 'HTTP %{http_code}\n' http://localhost:376/t76/slide-style
HTTP 200

# เนื้อในหน้าเว็บ
theme = [light]

# log ของ next dev — ไม่มีบรรทัด error เลย
 GET /t76/slide-style 200 in 96ms (next.js: 72ms, application-code: 24ms)

guard ที่เขียนไว้ไม่ทำงาน เพราะ "light" เป็น truthy · component ได้ค่า "light" ไปใช้แล้วดูเหมือนทุกอย่างปกติ
อันนี้แย่กว่าพัง — พังเราแก้ได้ทันที แต่ "ดูเหมือนถูก" จะไปโผล่เป็นบั๊กตอน production ว่าทำไมธีมไม่เปลี่ยนตาม
ทางแก้: ตั้งค่าเริ่มต้นเป็น undefined แล้วบอก type ให้ชัด createContext<ThemeBox | undefined>(undefined) — undefined เป็น falsy guard ถึงจะทำงาน (พิสูจน์แล้วในผลรันบนสุดของหัวข้อนี้)

ทำไม createContext(undefined) ถึงปลอดภัยกว่า createContext("light") ในทางปฏิบัติ

ตอบตัวเลือกที่ 2 · ประเด็นทั้งหมดคือ เปลี่ยนความผิดพลาดที่เงียบ ให้กลายเป็นความผิดพลาดที่ส่งเสียง · เราพิสูจน์ทั้งสองฝั่งด้วยผลรันจริงแล้ว: ค่าเริ่มต้น "light" ได้ HTTP 200 กับข้อความ theme = [light] ส่วนค่าเริ่มต้น undefined + guard ได้ HTTP 500 พร้อมชื่อไฟล์และเลขบรรทัด
ตัวเลือกที่ 1 ไม่จริง (React ยังไต่หา Provider เหมือนเดิม) · ตัวเลือกที่ 3 ไม่จริง (tsc ไม่เคยบังคับเรื่อง useMemo) · ตัวเลือกที่ 4 ไม่จริง Provider ส่ง object ได้อยู่แล้วถ้าประกาศ type ให้ตรง

6 · กับดัก 2 — value เป็น object literal ทำให้ลูกทุกตัววาดใหม่

บทที่ 75 พูดเรื่อง "ค่าเดิมแต่กล่องใหม่" ไปแล้ว กับดักนี้คือเรื่องเดียวกันเป๊ะ แค่ย้ายมาอยู่บน Provider
ทุกครั้งที่ component แม่วาดใหม่ บรรทัด value={{ n }} จะสร้าง object ก้อนใหม่ · React เทียบค่า context ด้วย Object.is ก้อนใหม่จึง "ไม่เท่า" ก้อนเดิมเสมอ ทั้งที่ข้างในเหมือนกันทุกตัวอักษร → consumer ทุกตัววาดใหม่หมด

ผมทำหน้าทดลอง app/t76/rerender/page.tsx ที่มี context สองช่องเทียบกัน ลูกทั้งสองตัวห่อด้วย memo เหมือนกัน และค่า n ไม่เคยเปลี่ยนเลย (เป็น 1 ตลอด) · ปุ่มที่กดแค่เปลี่ยน tick ซึ่งไม่เกี่ยวกับ context เลย:

const LiteralCtx = createContext<Box>({ n: 0 });
const MemoCtx    = createContext<Box>({ n: 0 });

const LiteralChild = memo(function LiteralChild() {
  const v = useContext(LiteralCtx);
  literalRenders += 1;
  console.log('[t76] LiteralChild render #' + literalRenders + ' n=' + v.n);
  return <p>literal: {v.n}</p>;
});
// MemoChild เขียนเหมือนกันทุกอย่าง ต่างแค่อ่านจาก MemoCtx

export default function RerenderPage() {
  const [tick, setTick] = useState(0);
  const n = 1;                                    // ค่าไม่เคยเปลี่ยนเลย
  const memoValue = useMemo<Box>(() => ({ n }), [n]);

  return (
    <main>
      <button onClick={() => setTick((t) => t + 1)}>tick = {tick}</button>

      <LiteralCtx value={{ n }}>          {/* object ก้อนใหม่ทุกรอบ */}
        <LiteralChild />
      </LiteralCtx>

      <MemoCtx value={memoValue}>         {/* ก้อนเดิมตราบใดที่ n ไม่เปลี่ยน */}
        <MemoChild />
      </MemoCtx>
    </main>
  );
}

กดปุ่ม 2 ครั้ง (tick 0 → 1 → 2) ทั้งที่ n ไม่เปลี่ยนเลย — LiteralChild กับ MemoChild ตัวไหนจะวาดใหม่บ้าง?

LiteralChild วาดใหม่ทุกครั้ง · MemoChild ไม่วาดใหม่เลยสักครั้ง
นี่คือคอนโซลจริงจากเบราว์เซอร์หลังกดปุ่มสองที (ก็อปมาทั้งดุ้น):

[t76] Parent render, tick=1
[t76] Parent render, tick=1
[t76] LiteralChild render #3 n=1
[t76] LiteralChild render #4 n=1
[t76] Parent render, tick=2
[t76] Parent render, tick=2
[t76] LiteralChild render #5 n=1
[t76] LiteralChild render #6 n=1

ไม่มีบรรทัดของ MemoChild โผล่มาเลย — มันค้างอยู่ที่ render #2 ตั้งแต่ตอนโหลดหน้า
สังเกตว่าทุกบรรทัดขึ้นซ้ำสองรอบ — อันนั้นคือ Strict Mode ของ React ตอน dev ที่ตั้งใจเรียก component สองรอบเพื่อจับ side effect ไม่ใช่บั๊ก และไม่เกิดตอน production
สังเกตอีกอย่าง: n=1 เท่าเดิมทุกบรรทัด — ค่าไม่เคยเปลี่ยน แต่ก็ยังวาดใหม่ เพราะสิ่งที่เปลี่ยนคือตัวกล่อง ไม่ใช่ค่าในกล่อง

เทียบกับของจริง

memo เหมือนยามหน้าตึกที่จำหน้าคนได้ — ถ้ามาคนเดิม ยามไม่ต้องเช็คบัตรใหม่
แต่ React ไม่ได้จำ "หน้าคน" มันจำเลขบัตรประชาชนของกล่องที่ถือมา · value={{ n: 1 }} คือการเอาของเดิมใส่กล่องใหม่ที่มีเลขใหม่ทุกครั้ง ยามเลยต้องเช็คใหม่ทุกรอบ ทั้งที่ของข้างในเหมือนเดิมเป๊ะ · useMemo คือการใช้กล่องใบเดิมซ้ำ ตราบใดที่ของข้างในยังเหมือนเดิม

ที่ไม่เหมือนกัน: ยามจริงเช็คบัตรช้าและเสียเวลา แต่ React วาด component ใหม่นั้น เร็วมากจนส่วนใหญ่ไม่มีใครรู้สึก
อย่าเอาไปเข้าใจว่า "ลืม useMemo = เว็บช้า" — มันจะกลายเป็นปัญหาก็ต่อเมื่อ consumer มีเยอะ หรือแต่ละตัววาดแล้วหนัก (เช่น กราฟ ตารางยาว ๆ)
และที่สำคัญ: ตัวเลข render ในคอนโซลข้างบนไม่ได้แปลว่าเว็บช้าลงจริง มันแค่ยืนยันว่า "วาดใหม่จริง" ถ้าจะบอกว่าช้าต้องวัดเวลาจริงอีกที

สรุปกฎที่จำง่าย: ถ้า value ของ Provider เป็น object หรือ array หรือ function ให้ห่อด้วย useMemo / useCallback · ถ้าเป็น string · number · boolean เช่น value="dark" ในสไลด์หน้า 53 ไม่ต้องห่อ เพราะค่าพวกนี้เทียบกันด้วยค่าตรง ๆ อยู่แล้ว
และย้ำจากบทที่ 75: React ในโปรเจกต์นี้เป็น 19.2.8 · next.config.ts ไม่ได้เปิด React Compiler ไว้ (ข้างในมีแค่คอมเมนต์ /* config options here */) — useMemo จึงยังจำเป็นอยู่ ไม่มีใครทำให้ฟรี

7 · กับดัก 3 — Provider ต้องเป็น client component

App Router ถือว่าทุกไฟล์เป็น Server Component จนกว่าจะเขียน 'use client' · และ createContext กับ useContext เป็นของฝั่ง client ล้วน ๆ
ผมลองทำหน้าที่ลืมใส่ 'use client' แล้วเรียกดู:

// app/t76/server-hook/page.tsx — ไม่มี 'use client'
import { createContext, useContext } from 'react';

const ThemeContext = createContext('light');

export default function ServerHookPage() {
  const theme = useContext(ThemeContext);
  return <p>{theme}</p>;
}

ผลรันจริง — HTTP 500 พร้อมข้อความนี้:

⨯ ./app/t76/server-hook/page.tsx:1:10
Error: You're importing a module that depends on `createContext` 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 { createContext, useContext } from 'react';
    |          ^^^^^^^^^^^^^
  2 |
  3 | const ThemeContext = createContext('light');
  4 |

Ecmascript file had an error

 GET /t76/server-hook 500 in 385ms (next.js: 341ms, application-code: 44ms)

⚠️ ผลข้างเคียงที่ผมไม่ได้คาดไว้ และเจอตอนรันจริง

ตอนไฟล์นั้นยังอยู่ในโปรเจกต์ หน้าอื่นที่ไม่เกี่ยวกันเลยก็พังไปด้วย — ผม curl ไป /t76/rerender ได้ HTTP 500 กลับมา ทั้งที่หน้านั้นเขียนถูกทุกอย่าง เพราะมันเป็น compile error ของทั้งแอป ไม่ใช่ runtime error ของหน้าเดียว
พอลบไฟล์นั้นทิ้ง /t76/rerender กลับมา 200 ทันที
บทเรียนที่ใช้ได้จริง: ถ้า next dev พังทั้งเว็บพร้อมกันหมด อย่าเพิ่งไล่แก้หน้าที่กำลังเปิดอยู่ — ให้ไปอ่านบรรทัดแรกของ error ว่ามันชี้ไปที่ไฟล์ไหน

ทางแก้ที่ถูกต้องไม่ใช่การใส่ 'use client' ที่หน้าเว็บ — ให้ใส่ที่ไฟล์ context แล้วหน้าเว็บ (ซึ่งยังเป็น Server Component) import Provider มาครอบได้ตามปกติ
ดูที่ app/t76/page.tsx ในหัวข้อ 3 — ไฟล์นั้นไม่มี 'use client' เลย แต่ครอบ <ThemeProvider> ได้ และ build ผ่านเป็น static (○ /t76 ในตารางผลลัพธ์)
เหตุผล: Server Component ส่ง Client Component ไปเป็น children ได้ สิ่งที่ห้ามคือการเรียก hook เองในไฟล์ที่เป็น server

app/t76/page.tsx ในหัวข้อ 3 ไม่มี 'use client' สักบรรทัด แต่ครอบ <ThemeProvider> ได้ และ build ออกมาเป็น static (○ /t76 ในตาราง) — เพราะอะไร

ตอบตัวเลือกที่ 1 · เส้นแบ่ง server/client อยู่ที่ "ไฟล์ไหนเรียก hook" ไม่ใช่ "ไฟล์ไหนมี Provider อยู่ในหน้าจอ"
ตัวเลือกที่ 2 ไม่จริง — หัวข้อ 7 พิสูจน์แล้วว่าถ้าไฟล์ server แตะ createContext เอง มันพังทันที ไม่มีใครเติมให้
ตัวเลือกที่ 3 ไม่จริง วิธี export ไม่เกี่ยวอะไรเลย · ตัวเลือกที่ 4 ก็ไม่จริง useContext เป็น hook เหมือนกัน เรียกฝั่ง server ไม่ได้
ผลที่ได้จริงจากการทำแบบนี้: หน้าเว็บยังเป็น static ได้ (ไม่เสีย performance) แต่กล่อง Provider กับ <UserBadge /> ข้างในกลายเป็น client — ได้ทั้งสองอย่าง นี่คือเหตุผลที่คู่มือ Next.js บอกให้วาง 'use client' ให้ลึกที่สุดเท่าที่ทำได้

8 · กับดัก 4 — context ไม่ใช่ตัวแทนของ redux

พอเห็นว่า "ส่งค่าไปทั้งเว็บได้โดยไม่ต้องส่ง prop" คนมักสรุปทันทีว่า "งั้นเอา context เก็บ state ทั้งแอปเลยสิ ไม่ต้องใช้ Redux / Zustand แล้ว" — อันนี้ต้องระวัง

จากผลรันในหัวข้อ 6 เราเห็นแล้วว่า เมื่อค่าใน context เปลี่ยน consumer ทุกตัววาดใหม่หมด ไม่มีการเลือกเฉพาะคนที่ใช้ field ที่เปลี่ยน
ถ้าเอา state ทั้งแอปยัดใส่ context ก้อนเดียว การกดปุ่มเปลี่ยนแค่ชื่อผู้ใช้ ก็จะทำให้ทุกหน้าจอที่แตะ context นั้นวาดใหม่พร้อมกันหมด

ลักษณะของข้อมูลเหมาะกับ context ไหมเพราะอะไร
ธีม · ภาษา · ข้อมูลผู้ใช้ที่ล็อกอินอยู่ เหมาะมาก เปลี่ยนนาน ๆ ที และเปลี่ยนทีก็ตั้งใจให้ทั้งหน้าเปลี่ยนตามอยู่แล้ว
ค่าที่ตั้งครั้งเดียวตอนเปิดแอป เช่น base URL ของ API เหมาะมาก ไม่เปลี่ยนเลย จึงไม่มีปัญหา re-render ตั้งแต่แรก
ค่าในฟอร์มที่พิมพ์อยู่ · ตำแหน่งเมาส์ · ค่าที่เปลี่ยนถี่มาก ไม่เหมาะ เปลี่ยนทุกการพิมพ์หนึ่งตัวอักษร แล้วลากทั้ง subtree ให้วาดใหม่ตาม
state ทั้งแอปที่มีหลายสิบ field ปนกัน ไม่เหมาะ context เลือกไม่ได้ว่าใครสนใจ field ไหน · เครื่องมืออย่าง Redux / Zustand ทำเรื่องนี้ได้
ข้อมูลที่ดึงมาจาก API แล้วต้อง cache · refetch · จัดการ loading ไม่เหมาะ context เก็บค่าได้อย่างเดียว ไม่มีเรื่อง cache หรือ retry ให้ · งานนี้เป็นของ data-fetching library

วิธีคิดที่ใช้ตัดสินใจได้จริง: context แก้ปัญหา "ส่งค่ายังไง" ไม่ได้แก้ปัญหา "จัดการ state ยังไง"
ถ้าปัญหาของคุณคือ prop drilling → context ตอบโจทย์เลย · ถ้าปัญหาคือ state ซับซ้อน มีหลายคนแก้ หลายที่ฟัง อยากได้ devtools ย้อนดูได้ → นั่นคนละปัญหา

ติดป้ายศัพท์ — ชุดที่ 2
referential equality
การเทียบว่า "เป็นวัตถุก้อนเดียวกันไหม" ไม่ใช่ "ข้างในเหมือนกันไหม" · React ใช้เกณฑ์นี้ (ผ่าน Object.is) ตัดสินว่าค่า context เปลี่ยนหรือยัง — นี่คือรากของกับดักหัวข้อ 6
Server Component / Client Component
ใน App Router ทุกไฟล์เป็น Server Component จนกว่าจะเขียน 'use client' บรรทัดบนสุด · hook ทุกตัวรวมถึง useContext ใช้ได้เฉพาะฝั่ง client
Strict Mode
โหมดของ React ตอน dev ที่ตั้งใจเรียก component สองรอบเพื่อจับ side effect ที่ไม่ควรมี · เป็นเหตุผลที่ log ในหัวข้อ 6 ขึ้นซ้ำสองบรรทัด และไม่เกิดตอน production

ทีมมีหน้า dashboard ที่ช่องค้นหาอัปเดตทุกตัวอักษรที่พิมพ์ และมีการ์ดสถิติ 30 ใบที่วาดกราฟใบละอัน · เพื่อนเสนอให้เอาข้อความในช่องค้นหาไปเก็บใน context ที่ครอบทั้งหน้า — ควรตอบว่าอะไร

ตอบตัวเลือกที่ 4 · ตัวเลือกที่ 1 ผิดตรงจุดสำคัญ — ผลรันในหัวข้อ 6 แสดงชัดว่า memo กันไม่อยู่ ถ้า component นั้นเป็น consumer ของ context ที่เปลี่ยน (LiteralChild ห่อ memo ไว้แต่ก็ยังวาดใหม่ #3–#6)
ตัวเลือกที่ 2 ผิดเพราะ useMemo ช่วยแค่ตอน "ค่าไม่ได้เปลี่ยนจริง" — แต่ข้อความค้นหาเปลี่ยนจริงทุกตัวอักษร ก้อนใหม่จึงถูกสร้างอยู่ดี
ตัวเลือกที่ 3 แรงเกินไป context เก็บค่าที่เปลี่ยนได้ (ธีมในบทนี้ก็เปลี่ยนได้ด้วยปุ่ม) — เกณฑ์จริงคือเปลี่ยนถี่แค่ไหน และมีคนฟังกี่คน ซึ่งคือสิ่งที่ตัวเลือกที่ 4 บอกให้ไปดูก่อน

9 · สรุปเป็นเช็คลิสต์ที่เอาไปใช้ได้เลย

ขั้นตอนทำอะไรถ้าลืมจะเจออะไร (ของจริงจากบทนี้)
1 สร้างไฟล์ context แล้วใส่ 'use client' บรรทัดบนสุด You're importing a module that depends on `createContext` into a React Server Component module. — และพังทั้งเว็บ ไม่ใช่แค่หน้าเดียว
2 ประกาศ type ให้ชัด ค่าเริ่มต้นเป็น undefined ถ้าใส่ค่าเริ่มต้นแบบ "light" → หน้าขึ้น HTTP 200 พร้อมค่าปลอม ไม่มีใครเตือน
3 เขียน custom hook ที่มี if (!ctx) throw แล้วให้ทุกคนเรียกผ่านตัวนี้ ถ้าเรียก useContext ตรง ๆ จะได้ TypeError: Cannot destructure property … as it is undefined ที่ไม่บอกว่าลืมอะไร
4 รับ children ใน Provider ให้ครบ TS2304: Cannot find name 'children' — สไลด์หน้า 54 พลาดข้อนี้
5 ถ้า value เป็น object / array / function ให้ห่อ useMemo consumer วาดใหม่ทุกครั้งที่แม่วาด แม้ค่าไม่เปลี่ยน (LiteralChild render #3…#6)
6 ครอบ Provider ให้อยู่เหนือคนที่จะใช้ ไม่ใช่ในไฟล์เดียวกับที่เรียก hook ได้ค่าเริ่มต้นแทนค่าจริง — สไลด์หน้า 55 เตือนเรื่องนี้ไว้ตรง ๆ

ถ้าจำได้แค่ประโยคเดียวจากบทนี้ ให้จำอันนี้:
context ไม่ได้ทำให้ค่าเดินทางเร็วขึ้น มันแค่ทำให้เราไม่ต้องเขียนทางเดินเอง — และค่าที่ผิดจะเงียบกว่าค่าที่ไม่มี

🛠 ภารกิจ — ทำให้ "ค่าปลอมเงียบ ๆ" กลายเป็น error ที่ส่งเสียง

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

  1. สร้าง components/ThemeCtx.tsx · บรรทัดแรก 'use client' · ข้างในทำ แบบสไลด์หน้า 54 ก่อน คือ createContext('light') แล้วเขียน hook useTheme() ที่มี if (!context) throw new Error(...) · แล้วทำ component เล็ก ๆ ที่โชว์ค่าที่ได้ออกมาบนหน้าจอ
  2. ทำหน้าที่เรียก component นั้น โดยไม่ครอบ Provider เลย · รัน npx next dev แล้วเปิดดู · จดว่าได้ HTTP อะไร และหน้าจอขึ้นข้อความว่าอะไร (guard ที่คุณเพิ่งเขียนทำงานไหม?)
  3. แก้บรรทัดเดียว: เปลี่ยนเป็น createContext<{ mode: string } | undefined>(undefined) · โหลดหน้าเดิมซ้ำ · จดว่าเปลี่ยนไปยังไง แล้วเทียบกับข้อ 2
  4. ครอบ Provider ให้ถูกต้อง แล้วรัน npx next build · ดูตาราง Route (app) ว่าหน้าของคุณขึ้น ○ หรือ ƒ · ลองถอด Provider ออกอีกครั้งแล้ว build ใหม่ — คราวนี้ build จะเป็นยังไง
  5. ลบ 'use client' ออกจากไฟล์ context โดยที่ page.tsx ยังเป็น Server Component เหมือนเดิม แล้วโหลดหน้าใหม่ · เปิดหน้าอื่นที่ไม่เกี่ยวข้องกันเลยดูด้วย
    แล้วลองอีกท่า: เติม 'use client' ไว้ที่ page.tsx แทน (ไฟล์ context ยังไม่มี) — คราวนี้ผลต่างไปไหม?
  6. โบนัส: ทำหน้าแบบหัวข้อ 6 · ปุ่มที่เปลี่ยน state ที่ไม่เกี่ยวกับ context · ลูกห่อ memo · Provider ตัวหนึ่งใช้ value={{ n }} อีกตัวใช้ useMemo · กดปุ่ม 2 ที แล้วนับ log

ข้อ 2 — ต้องได้ HTTP 200 และหน้าขึ้นค่า light ตามปกติ ไม่มี error สักตัว · guard ที่คุณเขียนไม่ทำงาน เพราะ "light" เป็น truthy · ถ้าคุณได้ 500 แปลว่าเผลอใส่ค่าเริ่มต้นเป็น undefined หรือ "" ไปแล้ว ให้ย้อนกลับไปทำข้อ 1 ให้ตรง

ข้อ 3 — คราวนี้ต้องได้ HTTP 500 พร้อมข้อความที่คุณเขียนเอง และ code frame ที่ชี้ชื่อไฟล์กับเลขบรรทัดของ throw · นี่คือความต่างทั้งหมด: บรรทัดเดียวเปลี่ยนบั๊กเงียบให้กลายเป็นบั๊กที่บอกทางแก้

ข้อ 4 — ครอบถูกแล้วควรได้ ○ (static) · พอถอด Provider ออกแล้ว build ใหม่ จะไม่ใช่แค่ "หน้านั้นพัง" แต่ next build ล้มทั้งโปรเจกต์ · ในเครื่องผมได้ Error occurred prerendering page "/t76/no-provider" ตามด้วย Export encountered an error … exiting the build และ ⨯ Next.js build worker exited with code: 1
ทางออกตอนอยากเก็บหน้าพังไว้ดูคือใส่ export const dynamic = 'force-dynamic' (นั่นคือเหตุผลที่หน้ากับดักในบทนี้ขึ้น ƒ ในตาราง)

ข้อ 5 ท่าแรก — ต้องพังแบบหัวข้อ 7 คือ You're importing a module that depends on `createContext` into a React Server Component module · และหน้าที่ไม่เกี่ยวข้องเลยก็ต้องพังไปด้วย เพราะเป็น compile error ของทั้งแอป ไม่ใช่ของหน้าเดียว · ในเครื่องผม /t76 กับ /t76/drilling ได้ 500 พร้อมกันทั้งคู่
คราวนี้ error จะแถม Import traces: มาให้ด้วย ซึ่งไล่ให้ดูเลยว่า ./components/…/theme.tsx ถูกลากเข้ามาผ่าน ./app/…/page.tsx — อ่านตรงนี้แล้วรู้ทันทีว่าต้องไปใส่ 'use client' ที่ไฟล์ไหน

ข้อ 5 ท่าที่สอง — กลับมาได้ HTTP 200 ทำงานปกติ เพราะพอ page.tsx ประกาศ 'use client' ทุกอย่างที่มัน import ก็ถูกนับเป็นฝั่ง client ไปด้วย · แต่นี่ไม่ใช่ท่าที่ควรใช้ — คุณเพิ่งลากทั้งหน้าไปเป็น client เพื่อแก้ปัญหาของไฟล์เดียว · ที่ถูกคือใส่ 'use client' ที่ไฟล์ context แล้วปล่อยให้หน้าเป็น server ต่อไป
บทเรียนของข้อนี้: "มันหายพัง" ไม่ได้แปลว่า "แก้ถูกที่"

ข้อ 6 — ตัวที่ใช้ value={{ n }} ต้องมี log โผล่ทุกครั้งที่กด ส่วนตัวที่ใช้ useMemo ต้องเงียบสนิท · ถ้าเลข render ของคุณขึ้นทีละ 2 ไม่ใช่ทีละ 1 นั่นคือ Strict Mode ตอน dev ไม่ใช่บั๊ก · และถ้าทั้งคู่เงียบ แปลว่าปุ่มของคุณไม่ได้ทำให้ component แม่วาดใหม่จริง ลองเช็คว่า setState อยู่ในแม่ตัวเดียวกับ Provider หรือเปล่า

หมายเหตุเรื่องวิธีตรวจ (เขียนไว้ให้ตรวจสอบซ้ำได้): ตอนผมรัน มี dev server ตัวอื่นจองโฟลเดอร์ทดสอบอยู่แล้ว และ Next.js ไม่ยอมให้เปิด next dev ซ้อนกันสองตัวในโฟลเดอร์เดียว (Another next dev server is already running.) · ผมจึงคัดลอกโปรเจกต์ทั้งชุดไปอีกโฟลเดอร์หนึ่งโดยใช้ node_modules · package.json · tsconfig.json · next.config.ts ชุดเดียวกันเป๊ะ แล้วรันที่พอร์ต 376
เวอร์ชันทุกตัวจึงเท่ากับที่แจ้งไว้ในกล่องบนสุดของหน้า และคุณรันซ้ำเองได้ด้วยโค้ดที่โชว์ไว้ทั้งหมด

อ้างอิง