🧰 ของที่ใช้ตรวจในบทนี้
# เวอร์ชันจริงบนเครื่องที่ใช้ตรวจ (อ่านจาก node_modules)
next 16.3.5
react 19.2.8
typescript 5.9.3
node v24.14.0
โปรเจกต์ที่ใช้ตรวจสร้างด้วย create-next-app · ทุกกล่องที่เขียนว่า "ผลรันจริง"
มาจาก npx next build แล้วเปิด npx next start -p 379 จากนั้น curl ไปถาม ·
พอร์ต 379 คือพอร์ตของเครื่องที่ใช้ตรวจ ในเครื่องคุณจะเป็น 3000 ·
โค้ดทุกก้อนที่ไม่ได้ติดป้ายว่า "พัง" ผ่านทั้ง npx next build ·
npx tsc --noEmit และ npx eslint . (exit 0 ทั้งสามตัว)
1 · app/ — สไลด์หน้า 7 และจุดที่ลอกตามตรง ๆ ไม่ได้
สไลด์หน้า 7 สรุปของใน app/ ไว้สี่บรรทัด แปลเป็นภาษาคนได้แบบนี้:
| ไฟล์ | สไลด์เขียนว่า | แปลว่า |
|---|---|---|
app/ |
compose route-level screens, layout, and default styles | ที่รวมหน้าจอแต่ละ URL · โครงหน้าที่ใช้ร่วมกัน · สไตล์ตั้งต้น |
layout.tsx |
root layout, wraps every page | กรอบที่ห่อทุกหน้า — ใส่ <html> <body> ฟอนต์ เมนูบน |
page.tsx |
page level to render interface | เนื้อหาของ URL นั้น ๆ · มีไฟล์นี้ในโฟลเดอร์ไหน โฟลเดอร์นั้นถึงจะเป็นหน้าเว็บ |
globals.css |
root styles of project example TailwindCSS | CSS ที่มีผลทั้งเว็บ · Tailwind ถูก @import เข้ามาตรงนี้ |
เรื่อง page.tsx กับ layout.tsx บทเรียน 70 รันให้ดูละเอียดแล้ว บทนี้จะไม่พูดซ้ำ ·
แต่ในรูปต้นไม้ของสไลด์หน้า 7 มีบรรทัดหนึ่งที่ต้องหยุดดู:
app/
├── layout.tsx
├── page.tsx
├── globals.css
└── [another-routes]/ ← สไลด์หน้า 7 บรรทัดที่ 6
└── page.tsx
⚠️ วงเล็บเหลี่ยมในชื่อโฟลเดอร์ไม่ใช่แค่ "ตัวอย่างชื่อ" — Next.js ถือว่ามันมีความหมาย
ผู้สอนน่าจะตั้งใจเขียน [another-routes] ให้อ่านว่า "ใส่ชื่อ route อื่น ๆ ตรงนี้" ·
แต่ถ้าคุณสร้างโฟลเดอร์ชื่อนี้จริง ทั้งวงเล็บ Next.js จะอ่านว่า
"ช่องนี้รับค่าอะไรก็ได้" (เรียกว่า dynamic segment — เหมือน /:id ใน Gin)
ผลรันจริง — สร้าง app/[another-routes]/page.tsx ที่พิมพ์ค่าที่ได้รับออกมา:
// app/[another-routes]/page.tsx — ใน Next.js 16 params เป็น Promise ต้อง await ก่อน
export default async function Page({
params,
}: {
params: Promise<{ "another-routes": string }>;
}) {
const p = await params;
return <p>dynamic: {p["another-routes"]}</p>;
}
แล้ว build:
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /[another-routes]
└ ○ /components
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
แล้วลอง curl ไปที่ URL ที่ไม่เคยสร้างไว้เลย:
$ curl -s http://localhost:379/recipes
<p>dynamic: <!-- -->recipes</p>
$ curl -s http://localhost:379/widgets
<p>dynamic: <!-- -->widgets</p>
ทุก URL ชั้นเดียวได้ 200 หมด ไม่มี 404 อีกต่อไป ·
เวลาทำแอปจริง ให้ตั้งชื่อโฟลเดอร์เป็นคำธรรมดา เช่น app/recipes/page.tsx ·
วงเล็บเหลี่ยมเก็บไว้ใช้ตอนที่ตั้งใจจะรับค่า เช่น app/recipes/[id]/page.tsx
(หน้ารายละเอียดสูตร) — กลุ่มนี้จะได้เจอแน่นอน
อีกสองจุดเล็ก ๆ ที่ต่างจากสไลด์: โปรเจกต์ที่ create-next-app สร้างให้บนเครื่องตรวจได้
next.config.ts (ไม่ใช่ .js — ภาพ VS Code ในสไลด์หน้า 6 ก็เป็น .ts) ·
และไม่มีไฟล์ .env / .env.local มาให้ —
สไลด์วาดไว้เพื่อบอกว่า "ถ้ามีจะอยู่ระดับบนสุด" คุณสร้างเองเมื่อต้องใช้
2 · components/ กับ containers/ — ใครวาด ใครคิด
สไลด์หน้า 8 เพิ่มโฟลเดอร์ระดับบนสุดอีกสองอัน · Next.js ไม่รู้จักชื่อทั้งสองนี้เลย มันเป็นข้อตกลงของทีม ไม่ใช่กฎของ framework · คำอธิบายในสไลด์คือ:
components/— pure and presentational of componentcontainers/— Stateful composition of components by business requirement
ต้นไม้ในสไลด์หน้า 8 (ชื่อ ComponentA–D เป็นชื่อสมมติของสไลด์):
.
├── app/
│ ├── _components/
│ │ └── ComponentC.tsx ← ใช้แค่หน้านี้ (หัวข้อ 4)
│ ├── _containers/
│ │ └── ContainerD.tsx
│ └── page.tsx
├── components/
│ └── ComponentA/ ← หนึ่งโฟลเดอร์ต่อหนึ่งตัว
│ ├── ComponentA.tsx
│ └── index.ts
└── containers/
└── ContainerB/
├── ContainerB.tsx
└── index.ts
แปลเป็นภาษาคน:
- component = ชิ้นส่วนที่ได้อะไรมาก็วาดตามนั้น · ไม่จำอะไรเอง ไม่รู้ว่าข้อมูลมาจากไหน ไม่รู้ว่าแอปนี้ทำธุรกิจอะไร · ส่ง props เหมือนเดิมเข้าไปกี่ครั้งก็ได้หน้าตาเหมือนเดิมทุกครั้ง (นี่คือคำว่า pure)
-
container = ชิ้นส่วนที่จำสถานะเอง (มี
useStateฯลฯ) และรู้ว่างานนี้ต้องการอะไร — เช่น "หน้ารายการสูตรต้องกรองได้ว่าเฉพาะเมนูเร็ว" · แล้วหยิบ component หลาย ๆ ตัวมาประกอบกันให้ได้หน้าตาตาม requirement นั้น
ของจริงในโปรเจกต์ตรวจ — ตามโครงของสไลด์หน้า 8 เป๊ะ (โฟลเดอร์ต่อหนึ่งตัว + index.ts):
// components/RecipeCard/RecipeCard.tsx — pure: ไม่มี state ไม่รู้ว่าข้อมูลมาจากไหน
export type RecipeCardProps = {
name: string;
minutes: number;
};
const RecipeCard = ({ name, minutes }: RecipeCardProps) => {
return (
<article className="rounded border p-3">
<h3>{name}</h3>
<p>{minutes} นาที</p>
</article>
);
};
export default RecipeCard;
// containers/RecipeList/RecipeList.tsx — stateful: จำว่ากดกรองอยู่ไหม + รู้กติกาธุรกิจ
"use client";
import { useState } from "react";
import { RecipeCard } from "@/components/RecipeCard";
const recipes = [
{ id: 1, name: "ข้าวผัดกะเพรา", minutes: 15 },
{ id: 2, name: "ต้มยำกุ้ง", minutes: 30 },
{ id: 3, name: "ไข่เจียว", minutes: 5 },
];
const RecipeList = () => {
const [quickOnly, setQuickOnly] = useState(false);
const shown = quickOnly ? recipes.filter((r) => r.minutes <= 15) : recipes;
return (
<section>
<button onClick={() => setQuickOnly(!quickOnly)}>
{quickOnly ? "แสดงทั้งหมด" : "เฉพาะเมนูไม่เกิน 15 นาที"}
</button>
{shown.map((r) => (
<RecipeCard key={r.id} name={r.name} minutes={r.minutes} />
))}
</section>
);
};
export default RecipeList;
index.ts ของทั้งสองโฟลเดอร์เป็น barrel แบบเดียวกับบทเรียน 71 ·
ส่วนแบนเนอร์ที่มีแค่หน้าแรกวางไว้ใน app/_components/ (หัวข้อ 4 อธิบายว่าทำไม):
// components/RecipeCard/index.ts (containers/RecipeList/index.ts เหมือนกัน เปลี่ยนแค่ชื่อ)
import RecipeCard from "./RecipeCard";
export { RecipeCard };
// app/_components/HomeBanner.tsx — pure และใช้แค่หน้าแรก
const HomeBanner = () => {
return <h1>Wongnok — สูตรอาหารวันนี้</h1>;
};
export default HomeBanner;
แล้ว app/page.tsx เหลือหน้าที่แค่วางของ:
// app/page.tsx
import HomeBanner from "./_components/HomeBanner";
import { RecipeList } from "@/containers/RecipeList";
export default function Home() {
return (
<main>
<HomeBanner />
<RecipeList />
</main>
);
}
ผลรันจริง — HTML ที่ server ส่งมา (ตัดเฉพาะ <main> จัดบรรทัดใหม่ เนื้อหาไม่แก้):
$ curl -s http://localhost:379/
<main>
<h1>Wongnok — สูตรอาหารวันนี้</h1>
<section>
<button>เฉพาะเมนูไม่เกิน 15 นาที</button>
<article class="rounded border p-3"><h3>ข้าวผัดกะเพรา</h3><p>15<!-- --> นาที</p></article>
<article class="rounded border p-3"><h3>ต้มยำกุ้ง</h3><p>30<!-- --> นาที</p></article>
<article class="rounded border p-3"><h3>ไข่เจียว</h3><p>5<!-- --> นาที</p></article>
</section>
</main>
สังเกตว่า RecipeCard.tsx ไม่มี "use client" ก็ใช้ได้
เพราะมันไม่มี hook เลย · ส่วน container ขาดไม่ได้ —
ผมลองลบสองบรรทัดแรกของ RecipeList.tsx ออกแล้ว build:
$ npx next build # (ผลรันจริง ตัดกรอบที่ชี้บรรทัดโค้ดออก · ข้อความ Error ยาวบรรทัดเดียว เลื่อนขวาดูได้)
> Build error occurred
Error: Turbopack build failed with 1 error:
./containers/RecipeList/RecipeList.tsx:1:10
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
Ecmascript file had an error
Import trace:
Server Component:
./containers/RecipeList/RecipeList.tsx
./app/page.tsx
นี่เป็นสัญญาณที่มีประโยชน์: ไฟล์ไหนต้องมี "use client"
มักเป็นไฟล์ที่ควรอยู่ใน containers/ · ถ้า component ที่ตั้งใจให้ "วาดอย่างเดียว"
เริ่มต้องการ "use client" เพราะมี state ของงานเข้าไปปน ให้ถามตัวเองว่ามันควรย้ายบ้านหรือยัง
(ข้อยกเว้นมีจริง — เช่นปุ่มที่จำได้แค่ว่ากำลังถูกกดค้าง ซึ่งเป็น state ของหน้าตา ไม่ใช่ของธุรกิจ)
component คือจาน ถ้วย ป้ายชื่อเมนู — วางอะไรลงไปก็โชว์อันนั้น
จานไม่รู้ว่านี่คือโต๊ะ 5 ไม่รู้ว่าลูกค้าแพ้กุ้ง ใช้จานใบเดียวกันเสิร์ฟร้านไหนก็ได้
container คือหัวหน้าเชฟ — จำว่าออร์เดอร์นี้สั่งอะไร ขอเผ็ดน้อยไหม
แล้วหยิบจานกับถ้วยที่เหมาะมาจัดให้ครบตามออร์เดอร์
ร้านที่ดีไม่เอาความจำเรื่องออร์เดอร์ไปติดไว้กับจาน เพราะวันหนึ่งจานต้องไปใช้กับออร์เดอร์อื่น
ที่ไม่เหมือนกัน: ในครัวจริงหัวหน้าเชฟมีคนเดียว ดูแลทุกโต๊ะพร้อมกัน
แต่ container เกิดใหม่ทุกครั้งที่ถูกวาง — วาง <RecipeList /> สองที่ในหน้าเดียว
ได้เชฟสองคนที่ความจำ (quickOnly) แยกกัน กดกรองอันหนึ่งอีกอันไม่เปลี่ยน ·
และความจำนั้นอยู่ในเบราว์เซอร์ของผู้ใช้แต่ละคน ไม่ได้อยู่ที่ server กลาง
· อีกข้อ: ครัวจริงมีกฎหน้าที่ชัด แต่ในโค้ดไม่มีอะไรบังคับว่าจานห้ามมี state —
ข้อตกลงนี้อยู่ได้ด้วยวินัยของทีมล้วน ๆ
- presentational component
- component ที่รับ props แล้ววาดอย่างเดียว ไม่มี state ของงาน ไม่ดึงข้อมูลเอง ·
ในรีโปนี้อยู่ใน
components/· พิมพ์บอก AI ได้ว่า “split this into a presentational component and a container that owns the state” - container component
- component ที่ถือ state และรู้ business rule แล้วประกอบ presentational component หลายตัวเข้าด้วยกัน · ชื่อนี้มาจากบทความของ Dan Abramov ปี 2015 ซึ่งภายหลังเขาเองเขียนหมายเหตุว่าไม่ได้แนะนำให้แบ่งแบบเคร่งครัดแล้ว (อ่านในแหล่งอ้างอิง) · ถาม senior ได้ว่า “ทีมนี้แยก container ออกมาเป็นโฟลเดอร์จริง หรือแค่แยกในหัว”
- pure component
- props เหมือนเดิม → หน้าตาเหมือนเดิมทุกครั้ง ไม่แอบไปแก้ของข้างนอก ·
React docs เรียกเรื่องนี้ว่า keeping components pure ·
ระวังสับสนกับ
React.PureComponentซึ่งเป็น class แบบเก่าที่เกี่ยวกับการข้าม render — คนละเรื่อง
3 · เทียบกับ Go — คล้ายแยก handler กับ service
ใน Gin (บท 38 เป็นต้นมา) คุณเคยแยกโค้ดเป็นชั้น: handler รับ request อ่าน input ตัดสินใจว่าจะเรียกอะไร แล้วส่งผลกลับ · ส่วนชิ้นเล็กที่ทำงานเฉพาะอย่าง (แปลง struct เป็น JSON, format วันที่) ไม่ต้องรู้เลยว่ามาจาก request ไหน
แนวคิดเดียวกันเป๊ะ: ของที่รู้บริบทและตัดสินใจอยู่ชั้นหนึ่ง · ของที่ทำงานตามที่ถูกป้อนอยู่อีกชั้น — ทำให้ของชั้นล่าง ย้ายไปใช้ที่อื่นได้และเทสง่าย
// Go — handler รู้งาน · ฟังก์ชันเล็กไม่รู้อะไรนอกจาก input ของมัน
func (h *RecipeHandler) List(c *gin.Context) {
quick := c.Query("quick") == "1" // ≈ state ของ container
recipes := h.svc.List(quick) // ≈ business rule
c.JSON(200, toRecipeCards(recipes)) // ≈ component: แปลงข้อมูลเป็นหน้าตา
}
(โค้ด Go ก้อนนี้เป็นภาพประกอบแนวคิด ไม่ใช่ไฟล์เต็ม จึงไม่มี package/import)
container ทำหน้าที่แบบ handler: อยู่ใกล้ผู้ใช้ รับเหตุการณ์เข้ามา
(กดปุ่ม = ยิง request) ตัดสินใจตาม business rule แล้วส่งข้อมูลที่เตรียมเสร็จให้ชั้นล่าง ·
component ทำหน้าที่แบบฟังก์ชัน toRecipeCards: ได้ข้อมูลมา แปลงเป็นหน้าตา จบ
ที่ไม่เหมือนกัน: handler ใน Go ลืมทุกอย่างเมื่อตอบ request เสร็จ
(stateless) state จริงไปอยู่ใน database · แต่ container จำ state ค้างไว้
ตราบที่ผู้ใช้ยังเปิดหน้านั้นอยู่ และวาดหน้าจอใหม่ทุกครั้งที่ state เปลี่ยน ·
อีกข้อ: ใน Go ชั้นต่าง ๆ มักแยกเป็น package และ compiler ห้าม import วนกัน (import cycle)
ทำให้โครงชั้นถูกบังคับอยู่บ้าง · ส่วน components/ กับ containers/ เป็นแค่ชื่อโฟลเดอร์
เอา useState ไปใส่ใน components/ ก็ build ผ่านสบาย ไม่มีใครฟ้อง ·
และ container มักรวม "handler + service" ไว้ในไฟล์เดียว ยังไม่ได้แยกชั้น business ออกไปอีกชั้นแบบ Go
- separation of concerns
- หลักที่ว่าโค้ดแต่ละชิ้นควรรับผิดชอบเรื่องเดียว — ใช้ได้ทั้ง handler/service ใน Go และ container/component ใน React · ถาม senior ได้ว่า “ใน frontend ของทีมนี้ business logic ควรอยู่ชั้นไหน — container, custom hook หรือ lib/”
- stateful / stateless
- stateful = จำค่าข้ามการ render ได้ (มี
useState) · stateless = ไม่จำอะไร ได้อะไรมาวาดอันนั้น · พิมพ์บอก AI ได้ว่า “keep this component stateless and lift the state up to its container” - lift state up
- ย้าย state ขึ้นไปไว้ที่ component แม่ แล้วส่งค่าลงมาเป็น props — วิธีทำให้ component ลูกกลับมา pure · เป็นชื่อหัวข้อจริงใน React docs (Sharing State Between Components)
4 · ขีดล่างหน้าชื่อโฟลเดอร์ — app/_components ไม่กลายเป็น URL
สไลด์หน้า 8 มีกล่อง Tips: “Component and container in the app/ folder are only used in themselves.”
แปลว่า ถ้าชิ้นส่วนนั้นใช้แค่หน้าเดียว ให้วางไว้ข้าง ๆ หน้านั้นเลย
ในโฟลเดอร์ _components/ หรือ _containers/ ใต้ app/
· ใช้หลายหน้าเมื่อไหร่ค่อยยกขึ้นไป components/ ระดับบนสุด
แต่บทเรียน 70 บอกว่าทุกโฟลเดอร์ใต้ app/ คือชิ้นส่วนของ URL ·
แล้วโฟลเดอร์ชื่อ _components จะกลายเป็นหน้า /_components หรือเปล่า?
ผมสร้างของสามแบบไว้เทียบกัน:
app/
├── page.tsx
├── _components/
│ ├── HomeBanner.tsx ← ถูก import โดย app/page.tsx
│ └── page.tsx ← ตั้งใจใส่ไว้ทดสอบ
├── components/ ← ชื่อเดียวกันแต่ "ไม่มี" ขีดล่าง
│ └── page.tsx
└── widgets/ ← ไม่มีขีดล่าง และไม่มี page.tsx
└── Card.tsxทั้ง _components/page.tsx และ components/page.tsx มีเนื้อหาแบบเดียวกัน:
// app/_components/page.tsx (อีกไฟล์เปลี่ยนแค่คำว่า _components เป็น components)
export default function Page() {
return <p>ถ้าเห็นข้อความนี้ แปลว่า _components กลายเป็นหน้าเว็บ</p>;
}
รัน npx next build แล้วตาราง Route จะมีกี่บรรทัด มี /_components ไหม?
แล้ว curl ไปที่ /_components · /components · /widgets จะได้ status อะไรบ้าง?
ผลรันจริง — ตาราง Route จาก npx next build:
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /components
○ (Static) prerendered as static content
ไม่มี /_components ทั้งที่มี page.tsx อยู่ในนั้นจริง · แล้ว curl:
$ for p in / /_components /components /widgets /widgets/Card /_components/HomeBanner; do ... curl -w '%{http_code}' ...
/ 200
/_components 404
/components 200
/widgets 404
/widgets/Card 404
/_components/HomeBanner 404
$ curl -s http://localhost:379/components
... <p>ถ้าเห็นข้อความนี้ แปลว่า components กลายเป็นหน้าเว็บ</p> ...
$ curl -s http://localhost:379/_components
... This page could not be found ...
สรุปจากผลจริง:
· ขีดล่างข้างหน้า = Next.js ข้ามทั้งโฟลเดอร์ มี page.tsx อยู่ข้างในก็ไม่สน
· ไม่มีขีดล่าง + มี page.tsx = กลายเป็นหน้าเว็บทันที (/components ได้ 200)
· widgets/ ไม่มีขีดล่างก็ได้ 404 เหมือนกัน — เพราะไม่มี page.tsx ตามกฎของบทเรียน 70
และ HomeBanner ใน _components/ ยัง import ใช้ได้ปกติ —
<h1>Wongnok — สูตรอาหารวันนี้</h1> โผล่ในหน้า / ตามหัวข้อ 2 ·
ขีดล่างปิดแค่การเป็น URL ไม่ได้ปิดการ import
ถ้า widgets/ ไม่มีขีดล่างก็ 404 อยู่แล้ว ขีดล่างมีไว้ทำไม? สองเหตุผล:
-
กันพลาดในอนาคต — วันที่มีคนเผลอสร้างไฟล์ชื่อ
page.tsx(หรือroute.ts) ลงไปในโฟลเดอร์นั้น เว็บจะไม่งอก URL แปลก ๆ ขึ้นมาเงียบ ๆ อย่างที่/componentsในตารางข้างบนงอกให้เห็นแล้ว -
บอกคนอ่าน — เปิดโฟลเดอร์
app/มาเห็นขีดล่าง รู้ทันทีว่า "อันนี้ไม่ใช่หน้าเว็บ เป็นของใช้ภายใน" ไม่ต้องไล่เปิดทีละโฟลเดอร์ว่ามีpage.tsxไหม
⚠️ ขีดล่างไม่ได้กันการ import ข้ามหน้า · สไลด์บอกว่าของใน app/_components
"only used in themselves" — นั่นคือข้อตกลง ไม่ใช่สิ่งที่ Next.js บังคับ ·
ผมลองให้ app/about/page.tsx เขียน import HomeBanner from "@/app/_components/HomeBanner"
→ build ผ่าน และ curl /about ได้ 200 พร้อม <h1>Wongnok — สูตรอาหารวันนี้</h1> ·
และจำหัวข้อ 1 ไว้ด้วย: ถ้ามีโฟลเดอร์ dynamic อย่าง app/[another-routes]/ อยู่ข้าง ๆ
URL /_components จะได้ 200 จากหน้า dynamic นั้นแทน (curl จริงได้ <p>dynamic: <!-- -->_components</p>)
— ไม่ใช่เพราะขีดล่างเสีย แต่เพราะวงเล็บเหลี่ยมรับทุกชื่อ
ร้านอาหารมีโต๊ะที่มีเลข (โต๊ะ 1 โต๊ะ 2) ลูกค้าเดินไปนั่งได้ = โฟลเดอร์ที่มี page.tsx กลายเป็น URL ·
ส่วนห้องเก็บของหลังร้านติดป้าย "เฉพาะพนักงาน" ไม่มีเลขโต๊ะ ลูกค้าเรียกหาไม่เจอ
แต่พนักงานเดินเข้าไปหยิบจานออกมาเสิร์ฟได้ตลอด = _components/ ไม่มี URL แต่ยัง import ได้
ที่ไม่เหมือนกัน: ห้องเก็บของจริงมักล็อกประตูหรือแยกของตามโซน
แต่ขีดล่างไม่ได้ล็อกอะไรเลยในโค้ด — หน้า /about หยิบ HomeBanner
จาก app/_components ไป build ผ่านสบาย (กล่องเตือนข้างบน) ·
และป้ายนี้กันได้แค่ "การเป็น URL ของโฟลเดอร์นั้นเอง" ·
ถ้ามีโต๊ะแบบ [another-routes] ที่รับลูกค้าทุกชื่อ URL /_components ก็ยังได้ 200 จากโต๊ะนั้น
- private folder
- ชื่อทางการใน Next.js docs ของโฟลเดอร์ใต้
app/ที่ขึ้นต้นด้วย_— "ไม่เข้าระบบ routing ทั้งโฟลเดอร์รวมโฟลเดอร์ลูก" · พิมพ์บอก AI ได้ว่า “move these page-only components into a private folder app/recipes/_components” - colocation
- การวางไฟล์ที่ใช้ด้วยกันไว้ใกล้กัน (component ของหน้าไหนอยู่ในโฟลเดอร์ของหน้านั้น) · Next.js docs ใช้คำนี้ในหน้า Project Structure · ถาม senior ได้ว่า “ทีมนี้ colocate component ไว้ใน app/ หรือรวมศูนย์ไว้ที่ components/ ทั้งหมด”
- dynamic segment
- โฟลเดอร์ชื่อในวงเล็บเหลี่ยม เช่น
[id]— รับค่าอะไรก็ได้ในตำแหน่งนั้นของ URL แล้วส่งเข้าparamsของ page · เทียบได้กับ/:idใน Gin · ถาม AI ได้ว่า “what is the difference between [id], [...slug] and [[...slug]] in the Next.js App Router”
5 · โค้ดแบบนี้ควรอยู่ไหน
กฎที่ใช้ตัดสินมีสองคำถาม ถามตามลำดับ:
ข้อ 1 — มันจำ state ของงาน หรือรู้ business rule ไหม? ใช่ → container · ไม่ → component
ข้อ 2 — ใช้กี่หน้า? หน้าเดียว → app/<หน้านั้น>/_components หรือ _containers · หลายหน้า → ระดับบนสุด
| โค้ด | ควรอยู่ | เหตุผล |
|---|---|---|
การ์ดสูตรอาหาร รับ name minutes แล้ววาด |
components/RecipeCard/ |
ไม่มี state · หน้ารายการ หน้าค้นหา หน้าโปรไฟล์ ใช้การ์ดหน้าตาเดียวกันได้ |
| ป้ายเวลา "15 นาที" ที่เปลี่ยนสีตามตัวเลข | components/ |
สีคิดจาก props ล้วน ๆ — props เดิมได้สีเดิมเสมอ · ยัง pure อยู่ แม้จะมี if |
| รายการสูตรที่มีปุ่มกรอง "เฉพาะเมนูเร็ว" | containers/RecipeList/ |
จำว่ากรองอยู่ไหม (useState) + รู้กติกา "เร็ว = ไม่เกิน 15 นาที" ซึ่งเป็น business rule |
| ฟอร์มเพิ่มสูตรใหม่ ที่ตรวจว่าชื่อห้ามว่างแล้วค่อยส่ง | containers/ |
ถือค่าที่พิมพ์ · มีกติกาตรวจ · ประกอบ input กับปุ่มหลายตัว |
| แบนเนอร์ต้อนรับที่มีแค่หน้าแรก | app/_components/ |
pure แต่ใช้ที่เดียว → วางข้างหน้าที่ใช้ · ขีดล่างกันไม่ให้เป็น URL |
| ตัวสลับแท็บ "วัตถุดิบ / วิธีทำ" ที่มีเฉพาะหน้ารายละเอียดสูตร | app/recipes/[id]/_containers/ |
จำว่าเปิดแท็บไหนอยู่ (stateful) แต่ไม่มีหน้าไหนใช้ซ้ำ |
| ปุ่มพื้นฐานที่ใช้ทั้งเว็บ | components/ (กลุ่มนี้บทถัดไปจะใช้ components/ui/ ของ shadcn) |
ใช้ทุกหน้า · state ที่มี (ถ้ามี) เป็นเรื่องหน้าตา ไม่ใช่เรื่องธุรกิจ |
มีไฟล์ app/_containers/page.tsx ที่ return <p>hi</p> และไม่มีโฟลเดอร์ dynamic ใด ๆ · build แล้ว curl ไปที่ /_containers จะได้อะไร?
ตัวเลือกที่ 3 ถูก · ผมสร้างไฟล์นี้จริงแล้ว build — ตาราง Route ไม่มี /_containers และ curl ได้ 404 ·
กฎดูแค่ขีดล่างตัวแรก ไม่ได้ดูคำที่ตามมา จึงได้ผลเดียวกับ _components ในหัวข้อ 4
ตัวเลือกที่ 1 คือกฎของโฟลเดอร์ไม่มีขีดล่าง (/components ได้ 200) ·
ตัวเลือกที่ 2 ผิดเพราะ build ผ่านจริง ไม่มี error ใด ๆ · ตัวเลือกที่ 4 ไม่มีกลไกแบบนั้นใน Next.js
จุดที่ต้องจำ: ขีดล่างหน้าชื่อโฟลเดอร์ใน app/ = ไม่ใช่ URL แม้มี page.tsx — แต่ยัง import ได้ปกติ
ไฟล์ไหนไม่ควรอยู่ใน components/ ตามนิยามของสไลด์หน้า 8?
ตัวเลือกที่ 1 ถูก · มันทั้งจำ state (รายการในตะกร้า) และรู้ business rule (ไม่เกิน 20 ชิ้น) ตรงกับนิยาม container: stateful composition … by business requirement
ตัวเลือกที่ 2, 3, 4 ได้ข้อมูลอะไรมาก็วาดอันนั้น ไม่จำอะไรเอง — pure ทั้งหมด ·
ตัวเลือกที่ 4 มี onClick ก็จริง แต่ปุ่มแค่ส่งต่อฟังก์ชันที่คนอื่นยื่นมา ไม่ได้ตัดสินใจเองว่าจะทำอะไร
จุดที่ต้องจำ: รับ event handler มาทาง props ไม่ได้ทำให้เป็น container — การถือ state และรู้กติกาธุรกิจต่างหาก
ลอกโครงสไลด์หน้า 7 ตรง ๆ โดยสร้างโฟลเดอร์ชื่อ app/[another-routes]/page.tsx จริง · curl ไปที่ /recipes ที่ไม่เคยสร้างจะได้อะไร?
ตัวเลือกที่ 4 ถูก · ตาราง Route จริงแสดง ƒ /[another-routes] และ curl /recipes ได้
<p>dynamic: <!-- -->recipes</p> (รันด้วย next start หลัง build)
ตัวเลือกที่ 1 คือสิ่งที่จะเกิดถ้าไม่มีโฟลเดอร์ dynamic · ตัวเลือกที่ 2 ผิดเพราะ build ผ่าน ·
ตัวเลือกที่ 3 ผิดเพราะผลที่โชว์มาจาก production server (next start) อยู่แล้ว
จุดที่ต้องจำ: ในชื่อโฟลเดอร์ใต้ app/ สัญลักษณ์มีความหมาย — [ ] = รับค่า · _ = ไม่ใช่ route
ข้อไหนอธิบายความต่างระหว่าง container ใน React กับ handler ใน Go ได้ถูกที่สุด?
ตัวเลือกที่ 2 ถูก · handler ใน Go จบงานเมื่อส่ง response แล้ว state จริงอยู่ใน database ·
container ถือ useState ไว้ในเบราว์เซอร์และวาดใหม่เมื่อค่าเปลี่ยน
ตัวเลือกที่ 1 ผิดสองจุด — container stateful และไม่มี framework บังคับการแยก ·
ตัวเลือกที่ 3 กลับด้าน · ตัวเลือกที่ 4 ผิดเพราะ Next.js ไม่รู้จักชื่อโฟลเดอร์ components/
error "use client" ในหัวข้อ 2 เกิดจากไฟล์ไม่มี directive ไม่ได้เกิดจากชื่อโฟลเดอร์
จุดที่ต้องจำ: components/containers เป็นข้อตกลงของทีม ไม่มีเครื่องมือตัวไหนตรวจให้ — ต้องรักษาเอง
6 · ภารกิจ — วางโครง Wongnok ให้พร้อมสำหรับบทถัดไป
🎯 ภารกิจ
- สร้าง
components/RecipeCard/RecipeCard.tsx+index.tsตามหัวข้อ 2 (pure ไม่มี"use client") - สร้าง
containers/RecipeList/RecipeList.tsx+index.tsที่มีปุ่มกรองเมนูเร็ว - สร้าง
app/_components/HomeBanner.tsxแล้วให้app/page.tsxใช้ทั้งHomeBannerและRecipeList - ใส่บั๊กเพื่อพิสูจน์: สร้าง
app/_components/page.tsxและapp/components/page.tsx(ไม่มีขีดล่าง) แล้วรันnpx next build· ก็อปตาราง Route เก็บไว้ - รัน
npx next startแล้วใช้curl -s -o /dev/null -w '%{http_code}\n'ยิงไปที่/_componentsและ/components· จดเลขที่ได้ - ลบ
app/components/กับapp/_components/page.tsxทิ้ง build ใหม่ให้ตาราง Route เหลือแค่/กับ/_not-found
ก่อนกดเฉลย ตอบให้ได้: ถ้าย้าย RecipeCard ไปไว้ใน app/_components/ แทน
มันจะยังทำงานไหม และทำไมถึง "ไม่ควร" ย้าย?
ข้อ 4–5 ถ้าทำถูก จะได้ผลตรงกับที่เครื่องตรวจได้จริง:
Route (app)
┌ ○ /
├ ○ /_not-found
└ ○ /components
/_components 404
/components 200
ถ้าคุณได้ /_components เป็น 200 ให้เช็คสองอย่าง: พิมพ์ขีดล่างครบไหม (ไม่ใช่ขีดกลาง -) ·
และมีโฟลเดอร์ชื่อในวงเล็บเหลี่ยมอยู่ใต้ app/ หรือเปล่า (หัวข้อ 1)
คำถามท้ายภารกิจ: ย้ายแล้วยังทำงาน — แก้ path ของ import ให้ถูกก็ build ผ่าน
เพราะขีดล่างปิดแค่การเป็น URL ไม่ได้ปิดการ import ·
แต่ไม่ควรย้าย เพราะ RecipeCard จะถูกใช้หลายหน้า (รายการ · ค้นหา · โปรไฟล์)
และ Tips ในสไลด์หน้า 8 บอกว่าของใน app/ ใช้แค่ในหน้านั้น ·
วางผิดบ้าน = คนต่อไปจะคิดว่ามันเป็นของเฉพาะหน้าแรก แล้วไปสร้างการ์ดซ้ำอีกใบ
7 · สรุป
| เรื่อง | จำแค่นี้พอ |
|---|---|
components/ |
วาดตาม props · ไม่จำ state ของงาน · ใช้ได้หลายหน้า |
containers/ |
ถือ state + รู้ business rule · ประกอบ component หลายตัว · มักต้องมี "use client" |
app/_components · app/_containers |
ของที่ใช้หน้าเดียว · ขีดล่าง = ไม่เป็น URL แม้มี page.tsx (404 จริง) · ยัง import ได้ |
[ชื่อ] ในสไลด์หน้า 7 |
ถ้าสร้างจริงทั้งวงเล็บ = dynamic route รับทุก URL ชั้นนั้น · ตั้งชื่อธรรมดาแทน |
| เทียบ Go | container ≈ handler · component ≈ ฟังก์ชันแปลงผลลัพธ์ · แต่ container จำ state และไม่มีใครบังคับการแยก |
✅ ถ้าตอบสามข้อนี้ได้ แปลว่าบทนี้ผ่านแล้ว
1. ปุ่ม "ถูกใจ" ที่จำว่ากดแล้วหรือยัง และส่งคำขอไปบันทึก — อยู่ components/ หรือ containers/?
2. app/_components/page.tsx ได้ status อะไร และถ้าลบขีดล่างออกล่ะ?
3. ทำไมตั้งชื่อโฟลเดอร์ว่า [another-routes] ถึงทำให้ไม่มี 404?
ตอบไม่ได้ข้อไหน หรืออยากรู้ว่าทีมจริงแบ่งโฟลเดอร์แบบอื่นยังไง (เช่นแบ่งตาม feature) — ถามกลับมาได้เลย
อ้างอิง
- Next.js — Project Structure —
อ่านหัวข้อ Private folders (กฎขีดล่างที่หัวข้อ 4 รันพิสูจน์) และ
Colocation ที่อธิบายว่าไฟล์ใน
app/ที่ไม่ใช่page/routeไม่ถูกเผยแพร่เป็น URL · เลื่อนดู Store project files outside of app กับ Store project files in top-level folders inside of app ด้วย — เป็นทางเลือกการจัดโฟลเดอร์ที่ Next.js ยอมรับทั้งคู่ ไม่มีแบบไหนผิด - Next.js — Dynamic Route Segments —
อ่านแค่ย่อหน้าแรกกับตัวอย่าง
[slug]ก็พอเข้าใจว่าทำไม[another-routes]ในสไลด์หน้า 7 ถึงรับทุก URL - Dan Abramov — Presentational and Container Components — อ่านกล่อง Update from 2019 ที่อยู่บนสุดก่อน ผู้ตั้งชื่อ pattern นี้เองบอกว่าไม่ได้แนะนำให้แบ่งเคร่งครัดแล้วเพราะมี Hooks · แล้วค่อยอ่านรายการ bullet สองชุดที่เทียบลักษณะของสองแบบ — ตรงกับนิยามในสไลด์หน้า 8
- React — Keeping Components Pure — อ่านหัวข้อ Purity: Components as formulas ความหมายของคำว่า pure ในสไลด์หน้า 8
- React — Sharing State Between Components — อ่านตัวอย่าง Lifting state up by example วิธีดึง state ออกจาก component ลูกขึ้นไปไว้ที่แม่ ซึ่งคือการสร้าง container
- Next.js —
use client— ลิงก์เดียวกับที่ error ในหัวข้อ 2 ชี้มา · อ่านย่อหน้าที่อธิบายว่า directive นี้ขีดเส้นแบ่ง server กับ client — ใส่ที่ container ไฟล์เดียว component ลูกที่มัน import เข้าไปก็ถูกนับเป็นฝั่ง client ตาม (เหตุผลที่RecipeCardไม่ต้องใส่เอง)