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

โครงโฟลเดอร์ของแอปจริง — components กับ containers ต่างกันยังไง

~20 นาที · ควรผ่าน บทเรียน 70 (ชื่อโฟลเดอร์คือ URL) และ บทเรียน 73 (useState กับ 'use client') มาก่อน

กลุ่มนี้เราจะสร้างแอปสูตรอาหารชื่อ Wongnok ทีละขั้นตามสไลด์ "Build the application"
ก่อนเขียนโค้ดสักบรรทัด ต้องตกลงกันก่อนว่าไฟล์ไหนไปอยู่ตรงไหน · สไลด์หน้า 7–8 วางกติกาไว้สามโฟลเดอร์: app/ · components/ · containers/ และมีโฟลเดอร์หน้าตาแปลก ๆ ขึ้นต้นด้วยขีดล่าง app/_components
บทนี้ตอบสามคำถาม: components กับ containers ต่างกันตรงไหน · ขีดล่างข้างหน้าชื่อโฟลเดอร์ทำอะไรจริง ๆ (ผม build แล้ว curl ให้ดู) · และจุดเล็ก ๆ ในสไลด์หน้า 7 ที่ถ้าลอกตามตรง ๆ เว็บจะรับทุก URL

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

# เวอร์ชันจริงบนเครื่องที่ใช้ตรวจ (อ่านจาก 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 · คำอธิบายในสไลด์คือ:

ต้นไม้ในสไลด์หน้า 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

แปลเป็นภาษาคน:

ของจริงในโปรเจกต์ตรวจ — ตามโครงของสไลด์หน้า 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 · component ≈ ฟังก์ชันแปลงผลลัพธ์

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 อยู่แล้ว ขีดล่างมีไว้ทำไม? สองเหตุผล:

  1. กันพลาดในอนาคต — วันที่มีคนเผลอสร้างไฟล์ชื่อ page.tsx (หรือ route.ts) ลงไปในโฟลเดอร์นั้น เว็บจะไม่งอก URL แปลก ๆ ขึ้นมาเงียบ ๆ อย่างที่ /components ในตารางข้างบนงอกให้เห็นแล้ว
  2. บอกคนอ่าน — เปิดโฟลเดอร์ 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 ให้พร้อมสำหรับบทถัดไป

🎯 ภารกิจ

  1. สร้าง components/RecipeCard/RecipeCard.tsx + index.ts ตามหัวข้อ 2 (pure ไม่มี "use client")
  2. สร้าง containers/RecipeList/RecipeList.tsx + index.ts ที่มีปุ่มกรองเมนูเร็ว
  3. สร้าง app/_components/HomeBanner.tsx แล้วให้ app/page.tsx ใช้ทั้ง HomeBanner และ RecipeList
  4. ใส่บั๊กเพื่อพิสูจน์: สร้าง app/_components/page.tsx และ app/components/page.tsx (ไม่มีขีดล่าง) แล้วรัน npx next build · ก็อปตาราง Route เก็บไว้
  5. รัน npx next start แล้วใช้ curl -s -o /dev/null -w '%{http_code}\n' ยิงไปที่ /_components และ /components · จดเลขที่ได้
  6. ลบ 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) — ถามกลับมาได้เลย

อ้างอิง