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

โจทย์หน้า All Recipes — mock data แล้วแยก container

~25 นาที · ควรผ่าน บทเรียน 84 (RecipeCard และ 4 สถานะ) · บทเรียน 71 (props) · บทเรียน 78 (key กับ "use client") มาก่อน

สไลด์สี่หน้าสุดท้าย (หน้า 47–50) ปิดเส้นเรื่องแอป Wongnok ด้วยการประกอบหน้า /recipes ให้เสร็จ ในสองจังหวะ: จังหวะแรกวางการ์ดปลอม 12 ใบลงตาราง (หน้า 48) · จังหวะที่สองย้ายรายการการ์ดไปอยู่ใน container ชื่อ RecipeList ที่ดึงข้อมูลจากไฟล์ mock (หน้า 49)
โค้ดทั้งสองหน้า build ผ่าน · แต่มีสี่จุดที่ควรหยุดถาม — key ที่ทำจากลำดับ · "use client" ในไฟล์ที่ไม่มี state สักตัว · ตาราง 4 คอลัมน์ที่บนมือถือเหลือการ์ดกว้างนิ้วเดียว · และข้อความ “8 recipes · page 1 of 7” ที่อยู่เหนือการ์ด 12 ใบ
ผมถอดโค้ดจากสไลด์ลงโปรเจกต์จริง แล้วรันพิสูจน์ทีละข้อ · ท้ายบทมีภารกิจต่อสถานะ Loading / Empty จากบท 84 เข้ากับหน้านี้ และตาราง recap ของกลุ่ม Next.js App ทั้งหมด

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

Next.js           v16.3.5   # npx next --version
react             19.2.8    # require('react/package.json').version
typescript        5.9.3
tailwindcss       4.3.3
@base-ui/react    1.8.0
eslint            9.39.5
node              v24.14.0

โปรเจกต์ที่ใช้ตรวจสร้างด้วย create-next-app + shadcn init + shadcn add button card badge skeleton · โค้ดทุกก้อนผ่าน npx next build · npx tsc --noEmit · npx eslint · ข้อความ warning ได้จาก log ของ npx next dev ตอน curl หน้าเว็บ · ผลการกดปุ่มได้จากการเปิดหน้าใน browser แล้วกดจริง
ทุกข้อความ error · ตัวเลข · ผลลัพธ์ในบทนี้ก็อปมาจากหน้าจอจริง

ของที่สไลด์ไม่ได้ให้มา ผมจึงเขียนตัวแทนขึ้นเอง: สไลด์หน้า 48–49 import ของสามอย่างที่ไม่มีโค้ดอยู่ในสไลด์ชุดนี้เลย — Container จาก @/components/Bases · RecipeCard จาก @/components/RecipeCard · และไฟล์ ./recipes ที่เก็บ RECIPES_MOCK_DATA
ผมเขียนตัวแทนแบบเรียบที่สุดให้ build ได้ (RecipeCard รับ name · level · imageUrl · owner ตามที่หน้า 49 ส่งเข้าไป) · ข้อมูล mock ใช้ชื่อเมนู 8 ชื่อที่อ่านได้จากภาพสไลด์หน้า 50 และไม่ใส่รูป เพราะไม่มีไฟล์รูปจริงให้ใช้
RecipeCard ใน บท 84 ออกแบบให้รับ recipe ก้อนเดียว แต่สไลด์หน้า 49 ส่ง props ทีละช่อง — บทนี้ยึดตามสไลด์ · หลักการทุกข้อในบทนี้ใช้ได้กับทั้งสองแบบ
ส่วน class wongnok-text-h2 / wongnok-text-body มาจาก globals.css ที่สไลด์หน้า 27 ให้ AI สร้าง (typography ที่ขึ้นต้นด้วย wongnok) — ในโปรเจกต์ตรวจไม่มี class นี้ ซึ่งไม่ทำให้ build พัง แค่ตัวหนังสือไม่มีสไตล์

โจทย์จากสไลด์ — หน้าที่ต้องได้

สไลด์หน้า 47 โชว์หน้าเว็บที่ localhost:3000/recipes ใต้ Navbar จากบทก่อน ๆ มีหัวข้อ All Recipes · บรรทัดรองสีเทา · แล้วเป็นตารางการ์ด 4 คอลัมน์ ทุกใบหน้าตาเหมือนกัน: รูปหมวกเชฟ · recipe 1, recipe 2, … · เจ้าของ JD john doe · ป้าย Easy

All Recipes 8 recipes from home cooks · page 1 of 7 ┌────────────┐┌────────────┐┌────────────┐┌────────────┐ │ (chef hat) ││ (chef hat) ││ (chef hat) ││ (chef hat) │ │recipe 1 ││recipe 2 ││recipe 3 ││recipe 4 │ │JD john doe ││JD john doe ││JD john doe ││JD john doe │ │Easy ││Easy ││Easy ││Easy │ └────────────┘└────────────┘└────────────┘└────────────┘ ┌────────────┐┌────────────┐┌────────────┐┌────────────┐ │recipe 5 ││recipe 6 ││recipe 7 ││recipe 8 │ … แถวที่ 3 ถูกขอบจอตัด └────────────┘└────────────┘└────────────┘└────────────┘

หน้า 50 คือผลสุดท้ายหลัง refactor: หน้าตาเดิมทุกอย่าง แต่การ์ดกลายเป็นเมนูจริง — Classic Creamy Carbonara (Chef Marco · Medium) · Avocado Toast with Poached Egg (Sarah Jenkins · Easy) · Beef Wellington (Gordon Ramsey Style · Hard) · Spicy Thai Basil Chicken (Pad Krapow) (Noy Somchai · Easy) · French Macarons (Chloe Dubois · Hard) · Mediterranean Chickpea Salad (Elena Rostova · Easy) · Homemade Tonkotsu Ramen (Kenji Sato · Hard) · Mushroom Risotto (Chef Marco · Medium) — และยังมีแถวที่ 3 โผล่ขอบล่าง แปลว่าข้อมูลจริงในสไลด์มีมากกว่า 8 ใบ แต่อ่านชื่อได้แค่ 8 ใบ

จังหวะที่ 1 — วางการ์ดปลอม 12 ใบ (สไลด์หน้า 48)

งานแรกยังไม่ต้องมีข้อมูลจริง แค่อยากเห็นว่าตารางกับการ์ดวางตัวถูกไหม สไลด์ใช้ท่า “สร้าง array เปล่า 12 ช่อง แล้ว map ออกมาเป็นการ์ด”:

// app/recipes/page.tsx — ถอดจากสไลด์หน้า 48 ทุกตัวอักษร
import { Container } from "@/components/Bases";
import RecipeCard from "@/components/RecipeCard";

const RecipesPage = () => {
  return (
    <div className="pt-10 pb-8">
      <Container>
        <p className="wongnok-text-h2">All Recipes</p>
        <p className="wongnok-text-body text-muted-foreground mt-2">
          8 recipes from home cooks · page 1 of 7
        </p>
        <div className="grid grid-cols-4 gap-4 mt-7">
          {Array.from({ length: 12 }).map((_, index) => (
            <RecipeCard
              key={`recipe-${index}`}
              name={`recipe ${index + 1}`}
              level="EASY"
              owner={{ name: "john doe" }}
            />
          ))}
        </div>
      </Container>
    </div>
  );
};

export default RecipesPage;

build ผ่าน · หน้าเป็น ○ (Static) · curl หน้า /recipes-v1 (ชื่อ route ที่ผมใช้เก็บเวอร์ชันหน้า 48 ไว้เทียบ) แล้วนับได้การ์ด 12 ใบจริง:

$ curl -s http://localhost:385/recipes-v1 | grep -o 'recipe [0-9]*<' | wc -l
      12
$ curl -s http://localhost:385/recipes-v1 | grep -o '8 recipes from home cooks[^<]*' | head -1
8 recipes from home cooks · page 1 of 7
// ทำไมสไลด์ต้องเขียน Array.from({ length: 12 })
// ไม่เขียน new Array(12) ซึ่งสั้นกว่า?
console.log(Array.from({ length: 3 }));
console.log(Array.from({ length: 3 }).map((_, index) => `recipe ${index + 1}`));
console.log(new Array(3).map((_, index) => `recipe ${index + 1}`));

สามบรรทัดนี้พิมพ์อะไรออกมา — บรรทัดที่ 2 กับ 3 ได้เหมือนกันไหม?

ผลรันจริงบน node v24.14.0:

[ undefined, undefined, undefined ]
[ 'recipe 1', 'recipe 2', 'recipe 3' ]
[ <3 empty items> ]

ไม่เหมือนกัน · new Array(3) ได้ array ที่ยาว 3 แต่ ทุกช่องเป็น “ช่องโหว่” (empty item) ไม่ใช่ undefined — และ .map() ข้ามช่องโหว่ทิ้งหมด ฟังก์ชันข้างในไม่ถูกเรียกเลยสักครั้ง ถ้าเขียนแบบนั้นใน JSX หน้าเว็บจะว่างเปล่าเงียบ ๆ ไม่มี error
Array.from({ length: 3 }) สร้างช่องที่มีค่า undefined จริง ๆ map จึงวิ่งครบ · _ ในสไลด์คือชื่อที่คนนิยมตั้งให้พารามิเตอร์ที่ตั้งใจไม่ใช้ (เราต้องการแค่ index)

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

การ์ด 12 ใบที่ชื่อ recipe 1 … recipe 12 คืออาหารพลาสติกในตู้โชว์หน้าร้านญี่ปุ่น
ร้านยังไม่ต้องทำซูชิจริงสักคำ ก็จัดตู้ได้แล้วว่าจานวางตรงไหน ตู้กี่ชั้น ป้ายราคาอ่านชัดไหม · พอเมนูจริงพร้อม ค่อยสลับของจริงเข้าไปในตำแหน่งเดิม — นี่คือหน้า 48 → หน้า 50 พอดี

ที่ไม่เหมือนกัน: อาหารพลาสติกไม่มีใครเผลอกิน แต่ข้อมูลปลอมในโค้ดหลุดขึ้น production ได้จริงและไม่มีอะไรฟ้อง — ข้อความ 8 recipes · page 1 of 7 ก็คือของปลอมชิ้นหนึ่งที่ติดมาถึงหน้า 50 แล้ว (ดูหัวข้อถัดไป)
และอาหารพลาสติกหน้าตาเหมือนของจริง แต่ mock data มัก “สวยเกินจริง” — ชื่อสั้นทุกใบ มีรูปทุกใบ · ของจริงจะมีชื่อยาวสามบรรทัดกับเมนูที่ไม่มีรูป ซึ่ง mock ไม่เคยทดสอบให้

สามอย่างในหน้า 48 ที่ติดมาถึงหน้า 50

⚠️ 1 · ข้อความ “8 recipes · page 1 of 7” เป็นข้อความ mock ไม่ใช่ตัวเลขจริง

หน้า 48 วาดการ์ด 12 ใบ แต่ข้อความบนหัวบอก 8 recipes · และทั้งโปรเจกต์ยังไม่มีระบบแบ่งหน้าเลย แต่บอกว่า page 1 of 7
ที่มาของมันเห็นได้ชัดในสไลด์หน้า 42 — นี่คือข้อความจากภาพออกแบบที่ Claude Design สร้างให้ ซึ่งถูกพิมพ์ตามลงโค้ดตรง ๆ · หน้า 49 ที่ refactor แล้วก็ยังพิมพ์ข้อความนี้ไว้เหมือนเดิม และหน้า 50 ก็ยังโชว์ข้อความนี้อยู่
ไม่ผิดสำหรับขั้น mock · แต่ต้องรู้ว่ามันคือของปลอม · ตัวเลขนี้ควรคำนวณจากข้อมูล เช่น `${state.recipes.length} recipes found` (เฉลยภารกิจท้ายบททำให้ดู) ส่วน “page 1 of 7” ต้องรอจนมีการแบ่งหน้าจริงจาก API

⚠️ 2 · grid-cols-4 คือ 4 คอลัมน์ทุกขนาดจอ

ไม่มี prefix อะไรนำหน้า = ใช้กับทุกความกว้าง · คิดเลขง่าย ๆ บนมือถือกว้าง 375px: หักขอบซ้ายขวาของ Container ตัวแทนที่ผมเขียน (px-4 = 16px × 2) กับช่องไฟ gap-4 สามช่อง (16px × 3) เหลือ 295px แบ่ง 4 ใบ = ใบละไม่ถึง 74px (ความกว้างราวนิ้วโป้งหนึ่งนิ้ว)
ทั้งที่ prompt ออกแบบในสไลด์หน้า 23 ขอไว้เองว่า “Grid of RecipeCard (4 columns desktop, 2 columns tablet, 1 column mobile)” · แก้เป็น grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 — รายละเอียดอยู่หัวข้อ responsive ข้างล่าง

⚠️ 3 · key ทำจาก index

key={`recipe-${index}`} ในหน้า 48 ไม่ทำให้เกิด warning เพราะ key ไม่ซ้ำกันจริง · และในหน้านี้ยังไม่เป็นบั๊กเพราะรายการไม่เคยเปลี่ยนลำดับ
หน้า 49 แก้เป็น key={recipe.id} ถูกต้องแล้ว — หัวข้อ key ข้างล่างจะพิสูจน์ว่าทำไมต้องเปลี่ยน

จังหวะที่ 2 — แยกรายการออกเป็น container (สไลด์หน้า 49)

สไลด์หน้า 49 เขียนเหตุผลไว้หนึ่งบรรทัด: “Split recipe list into container to handle business of recipe list”
คำว่า container ย้อนกลับไปที่สไลด์หน้า 8 ซึ่งแบ่งโฟลเดอร์ไว้สองแบบ:

โฟลเดอร์สไลด์หน้า 8 นิยามว่าในบทนี้คือ
components/ pure and presentational — รับค่ามาแล้ววาดอย่างเดียว RecipeCard — ไม่รู้ว่าข้อมูลมาจากไหน รู้แค่ว่าได้ name มาก็วาด name
containers/ stateful composition of components by business requirement RecipeList — รู้ว่า “รายการสูตรอาหาร” มาจากไหน แล้วเอา RecipeCard มาเรียงให้

โค้ดหน้า 49 มีสองก้อน · ผมวางไฟล์ตามโครงหน้า 8 (containers/RecipeList/RecipeList.tsx + index.ts) — สไลด์ไม่ได้บอกชื่อไฟล์ของ RecipeList ไว้ บอกแค่ว่ามันอยู่โฟลเดอร์เดียวกับ ./recipes

// containers/RecipeList/RecipeList.tsx — ถอดจากสไลด์หน้า 49 (ก้อนขวา) ทุกตัวอักษร
"use client";

import RecipeCard from "@/components/RecipeCard";
import { RECIPES_MOCK_DATA } from "./recipes";

const RecipeList = () => {
  return (
    <>
      {RECIPES_MOCK_DATA.map((recipe) => (
        <RecipeCard
          key={recipe.id}
          name={recipe.name}
          level={recipe.level}
          imageUrl={recipe.imageUrl}
          owner={recipe.owner}
        />
      ))}
    </>
  );
};

export default RecipeList;
// app/recipes/page.tsx — ส่วน JSX ตามสไลด์หน้า 49 (ก้อนซ้าย) · บรรทัด import ผมเติมเอง
import { Container } from "@/components/Bases";
import RecipeList from "@/containers/RecipeList";

const RecipesPage = () => {
  return (
    <div className="pt-10 pb-8">
      <Container>
        <p className="wongnok-text-h2">All Recipes</p>
        <p className="wongnok-text-body text-muted-foreground mt-2">
          8 recipes from home cooks · page 1 of 7
        </p>
        <div className="grid grid-cols-4 gap-4 mt-7">
          <RecipeList />
        </div>
      </Container>
    </div>
  );
};

export default RecipesPage;
// containers/RecipeList/recipes.ts — สไลด์ไม่ได้โชว์ไฟล์นี้ ผมเขียนจากภาพหน้า 50
import type { RecipeLevel, RecipeOwner } from "@/components/RecipeCard";

export type Recipe = {
  id: string;
  name: string;
  level: RecipeLevel;          // "EASY" | "MEDIUM" | "HARD"
  imageUrl?: string;
  owner: RecipeOwner;          // { name: string; imageUrl?: string }
};

export const RECIPES_MOCK_DATA: Recipe[] = [
  { id: "r001", name: "Classic Creamy Carbonara", level: "MEDIUM", owner: { name: "Chef Marco" } },
  { id: "r002", name: "Avocado Toast with Poached Egg", level: "EASY", owner: { name: "Sarah Jenkins" } },
  { id: "r003", name: "Beef Wellington", level: "HARD", owner: { name: "Gordon Ramsey Style" } },
  // … r004 Pad Krapow · r005 French Macarons · r006 Chickpea Salad
  // … r007 Tonkotsu Ramen · r008 Mushroom Risotto
];

ตัวแทน Container กับ RecipeCard ที่ผมใช้ตรวจ (ไม่ใช่โค้ดจากสไลด์ · ตัวเลขขนาดไฟล์ในหัวข้อ "use client" ข้างล่างวัดจากตัวแทนชุดนี้ ถ้า RecipeCard ของคุณต่างไป ตัวเลขจะต่างตาม):

// components/Bases/index.tsx — ตัวแทน
export const Container = ({ children }: { children: React.ReactNode }) => (
  <div className="mx-auto max-w-7xl px-4">{children}</div>
);

// components/RecipeCard/RecipeCard.tsx — ตัวแทน (index.ts export default + type ต่อ)
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export type RecipeLevel = "EASY" | "MEDIUM" | "HARD";
export type RecipeOwner = { name: string; imageUrl?: string };

type RecipeCardProps = { name: string; level: RecipeLevel; imageUrl?: string; owner: RecipeOwner };

const RecipeCard = ({ name, level, imageUrl, owner }: RecipeCardProps) => {
  return (
    <Card className="pt-0">
      <div className="aspect-[4/3] bg-muted">{imageUrl ? null : "no photo"}</div>
      <CardHeader>
        <CardTitle>{name}</CardTitle>
      </CardHeader>
      <CardContent>
        <p>{owner.name}</p>
        <Badge variant="secondary">{level}</Badge>
      </CardContent>
    </Card>
  );
};

export default RecipeCard;

ผลรัน: build ผ่าน · tsc และ eslint exit code 0 · curl หน้า /recipes เจอการ์ด 8 ใบ ชื่อเมนูตรงกับหน้า 50

$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /recipes
└ ○ /recipes-v1

○  (Static)  prerendered as static content
👨‍🍳 เทียบกับของจริง

RecipeCard คือจานเปล่าหนึ่งใบ — วางอะไรลงไปก็เสิร์ฟหน้าตานั้น จานไม่รู้และไม่ต้องรู้ว่าวันนี้ครัวทำเมนูอะไร
RecipeList คือหัวหน้าครัว — เป็นคนเดียวที่รู้ว่า “เมนูวันนี้มีอะไรบ้าง” แล้วหยิบจานมาจัดทีละใบ · หน้า page.tsx คือโต๊ะในร้าน ที่แค่บอกว่า “ตรงนี้วางอาหาร”
วันที่ย้ายจาก mock ไปเรียก API จริง คนที่ต้องเปลี่ยนมีแค่หัวหน้าครัว — จานกับโต๊ะไม่ต้องแตะ

ที่ไม่เหมือนกัน: หัวหน้าครัวจริงเป็นคนละคนกับจานชัดเจน แต่ใน React ทั้งสองอย่างเป็นฟังก์ชันหน้าตาเหมือนกันทุกประการ · ไม่มี keyword อะไรบอกว่าไฟล์ไหนเป็น component ไฟล์ไหนเป็น container — มีแค่ชื่อโฟลเดอร์ที่ทีมตกลงกันเอง ใครจะแอบ import ข้อมูลเข้าไปใน RecipeCard ก็ build ผ่าน
และสไลด์หน้า 8 นิยามว่า container เป็น stateful — แต่ RecipeList ในหน้า 49 ไม่มี state เลยสักตัว ซึ่งพาไปสู่คำถามเรื่อง "use client" ข้างล่าง

🗣 ศัพท์ที่เพิ่งเรียนไป
mock data
ข้อมูลปลอมที่เขียนไว้ในโค้ดตรง ๆ เพื่อทำหน้าจอให้เสร็จก่อนที่ API จะพร้อม · ตั้งชื่อให้ตะโกนว่าเป็นของปลอม (RECIPES_MOCK_DATA) เพื่อให้ค้นเจอง่ายตอนจะลบ · บอก AI ได้ว่า “generate 12 mock recipes matching the Recipe type, include one with a very long name and one without an image” — ขอเคสขอบไว้ด้วย ไม่ใช่ขอแค่ข้อมูลสวย ๆ
presentational component / container component
แบ่งตามหน้าที่: ตัวแรก “รับ props แล้ววาด” · ตัวหลัง “รู้ว่าข้อมูลมาจากไหน แล้วส่งต่อให้ตัวแรก” · ในบางทีมเรียกว่า dumb / smart component · ถาม senior ได้ว่า “ทีมเราแยก container ด้วยโฟลเดอร์ หรือด้วยการตั้งชื่อ”
hardcode
ค่าที่พิมพ์ตายตัวในโค้ดแทนที่จะคำนวณหรือดึงมา เช่น 8 recipes · page 1 of 7 · ไม่ผิดตอนทำ mock แต่ต้องมีรายการว่ามีกี่จุด · ประโยคที่ใช้ได้ใน code review: “ตัวเลขนี้ hardcode อยู่ ควร derive จาก recipes.length ไหม”

key — ทำไมหน้า 49 เปลี่ยนจาก index เป็น recipe.id

key คือป้ายชื่อที่ React ใช้จำว่าการ์ดใบไหนคือใบเดิมจากรอบก่อน
เริ่มจากกรณีที่ลืมใส่เลย · ผมลบบรรทัด key ออกจากทั้งสองเวอร์ชัน (หน้า 48 ที่เป็น server component → route /nokey · หน้า 49 ที่ติด "use client" → route /nokey-client ซึ่งตอนนั้นผมวางไฟล์ไว้ที่ components/RecipeListNoKey/) แล้ว curl ทีละหน้า · build ผ่านทั้งคู่ แต่ log ของ next dev ออกมาแบบนี้:

 GET /recipes 200 in 170ms (next.js: 145ms, application-code: 25ms)
Each child in a list should have a unique "key" prop. See https://react.dev/link/warning-keys for more information.
 GET /nokey 200 in 125ms (next.js: 98ms, application-code: 27ms)
Each child in a list should have a unique "key" prop.

Check the render method of `RecipeList`. See https://react.dev/link/warning-keys for more information.
 GET /nokey-client 200 in 85ms (next.js: 71ms, application-code: 14ms)

อ่าน log นี้ให้เป็น: ข้อความ warning ขึ้นก่อนบรรทัด GET ของหน้าที่ทำให้เกิด (หน้า /recipes ที่มี key ครบไม่มี warning) · เวอร์ชัน client บอกชื่อ component ให้ด้วย (Check the render method of `RecipeList`) ส่วนเวอร์ชัน server ไม่บอก · และ eslint จับได้ทั้งคู่เป็น error (ผมตัด path เต็มของเครื่องที่ใช้ตรวจออกเป็น ...):

$ npx eslint app/nokey components/RecipeListNoKey
.../app/nokey/page.tsx
  14:13  error  Missing "key" prop for element in iterator  react/jsx-key

.../components/RecipeListNoKey/index.tsx
  10:9  error  Missing "key" prop for element in iterator  react/jsx-key

✖ 2 problems (2 errors, 0 warnings)

ลืมใส่มีคนฟ้อง · ใส่เป็น index ไม่มีใครฟ้องเลย — นั่นคือเหตุผลที่ข้อถัดไปอันตรายกว่า
ผมทำหน้าทดลองหนึ่งหน้า: แต่ละแถวมีปุ่มหัวใจที่เก็บ useState ของตัวเอง (ตรงกับ “Is favorite?” ในสไลด์หน้า 45) · วางรายการเดียวกันสองชุด ชุดบนใช้ key จาก index ชุดล่างใช้ key จาก id · มีปุ่ม remove first ลบเมนูแรกออกจากทั้งสองชุดพร้อมกัน

// ส่วนสำคัญของหน้าทดลอง (ไฟล์เต็มมี "use client" + import useState)
const FavoriteRow = ({ name }: { name: string }) => {
  const [isFavorite, setIsFavorite] = useState(false);
  return (
    <li>
      <button onClick={() => setIsFavorite(!isFavorite)}>
        {isFavorite ? "♥" : "♡"} {name}
      </button>
    </li>
  );
};

// ชุดบน
{recipes.map((recipe, index) => <FavoriteRow key={`recipe-${index}`} name={recipe.name} />)}
// ชุดล่าง
{recipes.map((recipe) => <FavoriteRow key={recipe.id} name={recipe.name} />)}

// ปุ่ม remove first
<button onClick={() => setRecipes(recipes.slice(1))}>remove first</button>
// ขั้นที่ 1: กดหัวใจ Carbonara ทั้งสองชุด
key=index                          key=id
♥ Classic Creamy Carbonara         ♥ Classic Creamy Carbonara
♡ Avocado Toast with Poached Egg   ♡ Avocado Toast with Poached Egg
♡ Beef Wellington                  ♡ Beef Wellington

// ขั้นที่ 2: กด remove first → Carbonara หายไปจากทั้งสองชุด

หลังกด remove first แต่ละชุดจะเหลือหัวใจทึบอยู่ที่ไหน?

ผลจริงจากการกดใน browser (อ่านข้อความบนหน้าหลังกด):

remove first
key=index
♥ Avocado Toast with Poached Egg
♡ Beef Wellington
key=id
♡ Avocado Toast with Poached Egg
♡ Beef Wellington

ชุด key=index: หัวใจย้ายไปอยู่ที่ Avocado Toast ทั้งที่ไม่มีใครกด
เพราะ React เห็นว่ารอบก่อนมี key recipe-0, recipe-1, recipe-2 รอบนี้มี recipe-0, recipe-1 → มันสรุปว่า “ใบสุดท้ายหายไป สองใบแรกยังอยู่ที่เดิม แค่ชื่อเปลี่ยน” · state หัวใจของ recipe-0 จึงอยู่ต่อ แล้วไปติดกับชื่อใหม่ที่ขยับขึ้นมาแทน
ชุด key=id ถูกต้อง — r001 หายไปพร้อม state ของมัน r002 ยังเป็นใบเดิมที่ไม่เคยถูกกด
ในแอปจริง อาการนี้คือ “ผู้ใช้กด favorite เมนูหนึ่ง แล้วหลังกรอง / เรียง / ลบ หัวใจไปโผล่อีกเมนู” — และไม่มี warning ที่ไหนเลย: ผมกดซ้ำอีกรอบแล้วอ่าน console ของ browser เจอแค่ข้อความชวนติดตั้ง React DevTools กับ [HMR] connected

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

key จาก index คือร้านกาแฟที่จำลูกค้าตามลำดับคิว — “คิวที่ 1 สั่งลาเต้หวานน้อย”
คนแรกเปลี่ยนใจเดินออกจากคิวไป คนที่สองขยับขึ้นมาเป็นคิวที่ 1 · บาริสต้ายื่นลาเต้หวานน้อยให้คนที่สองทันที ทั้งที่เขาสั่งอเมริกาโน่
key จาก id คือเขียนชื่อลูกค้าบนแก้ว — ใครออกจากคิวไป แก้วของคนนั้นก็หายไปด้วย คนอื่นไม่โดนสลับ

ที่ไม่เหมือนกัน: บาริสต้าจริงเห็นหน้าลูกค้า รู้สึกได้ว่าผิดคน แต่ React ไม่มีทางรู้เลยว่าเมนูที่ขยับขึ้นมาเป็นคนละเมนู — มันเชื่อ key อย่างเดียว
และถ้าคิวไม่เคยขยับ (รายการคงที่ ไม่มี state ข้างใน เหมือนการ์ดปลอม 12 ใบในหน้า 48) การจำตามลำดับก็ไม่ผิดอะไรเลย · ปัญหาเกิดตอนรายการเปลี่ยนเท่านั้น

🗣 ศัพท์เรื่อง key
stable key / stable identity
key ที่ติดกับ “ตัวข้อมูล” ไม่ใช่ “ตำแหน่ง” — ย้ายที่ กรอง เรียงใหม่ ก็ยังเป็นค่าเดิม · ปกติคือ id จากฐานข้อมูล · ถาม senior ได้ว่า “API ของ recipe มี id ที่ unique และไม่เปลี่ยนไหม จะใช้เป็น key”
reconciliation
ขั้นที่ React เทียบ UI รอบก่อนกับรอบนี้ เพื่อตัดสินว่าอะไรคงไว้ อะไรสร้างใหม่ อะไรทิ้ง · key คือสิ่งที่มันใช้จับคู่ลูกในรายการ · พิมพ์บอก AI ได้ว่า “state jumps to another row after I remove an item — check my list keys”
react/jsx-key
ชื่อกฎ eslint ที่จับ “ลืมใส่ key” · จับไม่ได้ว่าใช้ index · เวลาเห็นชื่อกฎนี้ใน CI ให้รู้ว่าเป็นเรื่อง key ทันที

"use client" ใน RecipeList — จำเป็นไหม

RecipeList หน้า 49 ไม่มี useState · ไม่มี useEffect · ไม่มี onClick · มีแค่ .map() ข้อมูลที่ import มา
ใน บท 78 เราเห็นแล้วว่า ลืม "use client" ในไฟล์ที่มี hook → build พัง · คำถามกลับด้าน: ใส่ในไฟล์ที่ไม่มี hook เลย จะเกิดอะไร
อย่าเดา · ผม build ทั้งสองแบบ (ลบ .next ทิ้งก่อนทุกครั้ง) แล้ววัดของที่ต่างกันจริง

// แบบ A — ตามสไลด์หน้า 49
"use client";
import RecipeCard from "@/components/RecipeCard";
import { RECIPES_MOCK_DATA } from "./recipes";

// แบบ B — ลบสองบรรทัดแรกทิ้ง ที่เหลือเหมือนเดิมทุกตัวอักษร
import RecipeCard from "@/components/RecipeCard";
import { RECIPES_MOCK_DATA } from "./recipes";

แบบ B build ผ่านไหม? ถ้าผ่าน หน้า /recipes ต่างจากแบบ A ตรงไหน?

ผ่านทั้งคู่ · ตาราง route เหมือนกันทุกบรรทัด · tsc และ eslint exit code 0 ทั้งคู่ · ชื่อเมนูอยู่ใน HTML ที่ prerender ไว้ทั้งคู่ (grep -o 'Mushroom Risotto' เจอทั้งสองแบบ) · หน้าตาบนจอเหมือนกัน
ความต่างอยู่ที่ JavaScript ที่ส่งไปให้ browser — ผมเขียนสคริปต์อ่านแท็ก <script src> ใน .next/server/app/recipes.html แล้วรวมขนาดไฟล์ JS ที่หน้านี้โหลด พร้อมค้นว่าไฟล์ไหนมีคำว่า Mushroom Risotto (ป้าย A / B หน้าบรรทัดคือชื่อรอบที่ผมส่งให้สคริปต์):

A html=21153 html_gz=3222 js_files=7 js=599772 js_gz=186078 total_gz=189300 ['static/chunks/1ltawcz6gcos9.js'] html_hits 1
B html=37091 html_gz=3775 js_files=7 js=565979 js_gz=172654 total_gz=176429 [] html_hits 2

ไฟล์ที่มีข้อมูล mock ของแบบ A:

$ f=$(grep -rl "Mushroom Risotto" .next/static/chunks)
$ ls -l $f | awk '{print $5}'      # ขนาดไฟล์เป็นไบต์
34057
$ gzip -c $f | wc -c
   13671
$ grep -o 'Classic Creamy Carbonara\|Gordon Ramsey Style\|base-ui\|no photo' $f | sort | uniq -c
   1 Classic Creamy Carbonara
   1 Gordon Ramsey Style
   1 base-ui
   1 no photo

แปลว่า: แบบ A ส่งไฟล์ JS ขนาด 34,057 ไบต์ (13,671 ไบต์หลัง gzip) ที่แบบ B ไม่มี ข้างในมีข้อมูล mock ทั้งก้อน · โค้ด RecipeCard · และ Badge จาก @base-ui ที่ RecipeCard ลากมาด้วย · (จำนวนไฟล์ที่หน้าโหลดยังเป็น 7 เท่ากัน แต่รวม JS ของแบบ B เล็กกว่า 33,793 ไบต์)
แต่ยังไม่จบ — ผลข้างบนคือกรณีที่ RecipeCard ไม่มี "use client" ส่วน RecipeCard ใน บท 84 มี (เพราะมี onClick) · ผมจึงวัดครบทั้ง 4 แบบ · ตัวเลขคือขนาดไฟล์ HTML ของหน้า + ไฟล์ JS ทุกไฟล์ที่หน้านี้โหลด บีบด้วย gzip ระดับ 9 (ไบต์ · ป้าย A–D หน้าบรรทัดผมพิมพ์เอง):

# A = card server · list client (ตามสไลด์)   B = card server · list server
# C = card client · list server              D = card client · list client
A html=21153 html_gz=3223 js_files=7 js=599772 js_gz=186078 total_gz=189301 ['static/chunks/1ltawcz6gcos9.js'] html_hits 1
B html=37091 html_gz=3775 js_files=7 js=565979 js_gz=172654 total_gz=176429 [] html_hits 2
C html=22333 html_gz=3380 js_files=7 js=598834 js_gz=185664 total_gz=189044 [] html_hits 2
D html=21153 html_gz=3221 js_files=7 js=599738 js_gz=186066 total_gz=189287 ['static/chunks/3t8oefhj0iumv.js'] html_hits 1

(html_hits = จำนวนครั้งที่เจอ Mushroom Risotto ใน HTML · ตัวเลขขยับได้ 1–2 ไบต์ระหว่างการ build แต่ละรอบ)

RecipeCardลบ "use client" ออกจาก RecipeList แล้วผลรวมหลัง gzip
ไม่มี "use client" (A → B) ไฟล์ JS ที่มีข้อมูล mock หายไป · แต่ HTML โตขึ้น 21,153 → 37,091 ไบต์ 189,301 → 176,429 · เล็กลง 12,872 ไบต์
มี "use client" แบบบท 84 (D → C) ข้อมูล mock ย้ายจากไฟล์ JS ไปอยู่ใน HTML 189,287 → 189,044 · เล็กลงแค่ 243 ไบต์

HTML โตขึ้นเพราะอะไร: หน้าใน App Router ส่ง HTML ที่วาดแล้วพร้อมกับข้อมูลโครงหน้า (RSC payload) ฝังท้ายไฟล์ · พอการ์ดวาดบน server ทั้งหมด ข้อมูลโครงหน้าก็ต้องบรรยายการ์ดทุกใบพร้อม class ยาว ๆ ของ shadcn · กรณี C ข้อมูล mock ก็ยังต้องเดินทางไป browser อยู่ดี เพราะการ์ดที่เป็น client ต้องได้ props ไปวาดใหม่ (grep -o 'Mushroom Risotto' ใน HTML ของกรณี C เจอ 2 ครั้ง · ไม่เจอในไฟล์ JS)
ข้อสรุปตามผลจริง: "use client" ในหน้า 49 ไม่จำเป็น — build ผ่านทั้ง 4 แบบ · route เป็น ○ Static ทุกแบบ · ส่วน “เอาออกแล้วได้อะไร” ขึ้นกับลูกของมัน: ถ้าลูกเป็น client อยู่แล้ว ต่างกันแค่หลักร้อยไบต์ · ถ้าลูกเป็น server ได้หลักหมื่นไบต์ · ใส่ไว้ไม่ผิด แต่อย่าใส่เพราะคิดว่า container ต้องเป็น client

✅ แล้วเมื่อไหร่ RecipeList ต้องมี "use client" จริง

ตอนที่มันเริ่มเป็น stateful ตามนิยามสไลด์หน้า 8 จริง ๆ — เช่นเก็บคำค้นด้วย useState (ช่อง “Search recipes by name” ในสไลด์หน้า 42) · หรือส่งฟังก์ชัน onClick ลงไปให้การ์ด (บท 84 หัวข้อ 4 รันให้ดูแล้วว่าส่งฟังก์ชันจาก server ไป client ไม่ได้ ได้ 500)
ก่อนถึงตอนนั้นปล่อยเป็น Server Component ไว้ · และถ้าจะวัดว่าคุ้มไหม ให้วัดทั้ง JS และ HTML อย่างที่ทำข้างบน ไม่ใช่ดูแค่ JS

🗣 ศัพท์เรื่องขอบเขต client
client boundary
ไฟล์ที่ติด "use client" คือเส้นแบ่ง — ไฟล์นั้นและทุกอย่างที่มัน import ถูกส่งเป็น JS ไปให้ browser ด้วย (RecipeCard กับข้อมูล mock ในกรณี A ข้างบน) · ถาม senior ได้ว่า “boundary ตรงนี้ดันลงไปที่การ์ดใบเดียวได้ไหม”
client bundle
ไฟล์ JavaScript ที่ browser ต้องดาวน์โหลด · ตรวจเองได้ด้วยการดูไฟล์ใน .next/static/chunks หลัง build อย่างที่ทำข้างบน · อย่าลืมว่า HTML ก็มีน้ำหนัก (RSC payload) — ย้ายของออกจาก bundle บางทีแค่ย้ายไปอยู่ใน HTML · พิมพ์บอก AI ได้ว่า “does this component need to be a client component? it has no hooks or event handlers”

ทำให้ตาราง responsive

Tailwind เขียนแบบ mobile-first: class ที่ไม่มี prefix ใช้กับทุกจอ · sm: ใช้ตั้งแต่จอกว้างขึ้นไป · lg: ใช้ตั้งแต่จอที่กว้างกว่านั้น (ทบทวนได้ใน บท 72)

สไลด์หน้า 48–49

<div className="grid grid-cols-4 gap-4 mt-7">

4 คอลัมน์ทุกจอ · มือถือ 375px ได้การ์ดใบละไม่ถึง 74px

ที่เสนอ

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-7">

มือถือ 1 · แท็บเล็ต 2 · จอใหญ่ 4 ตรงกับ prompt หน้า 23

ขอบของ sm กับ lg อยู่ที่เท่าไหร่ — ไม่ต้องจำ ดูจาก CSS ที่ build ออกมาจริง:

$ grep -o '@media[^{]*{[^{]*sm\\:grid-cols-2' .next/static/chunks/*.css
@media (min-width:40rem){.sm\:grid-cols-2
$ grep -o '@media[^{]*{[^{]*lg\\:grid-cols-4' .next/static/chunks/*.css
@media (min-width:64rem){.lg\:grid-cols-4

40rem = 640px · 64rem = 1024px (1rem = 16px ตามค่าเริ่มต้นของ browser) · จอกว้าง 375px ได้ 1 คอลัมน์ · 700px ได้ 2 · 1280px ได้ 4

ภารกิจ — ต่อสถานะ Loading / Empty เข้ากับหน้านี้

สไลด์หน้า 42–43 ออกแบบหน้า All Recipes ไว้ 4 สถานะ (Default · Empty · Loading · Error) และ บท 84 ทำ RecipeListState ไว้เป็น union ที่มีป้าย status แล้ว (ไม่ใช้ตัวแปรแยก isLoading / error เพราะมันเปิดช่องให้เกิดสถานะที่เป็นไปไม่ได้) · แต่ RecipeList ในหน้า 49 ทำได้แค่ Default เพราะมันดึงข้อมูลเองจากไฟล์ mock ตรง ๆ ไม่มีทางบอกมันว่า “กำลังโหลด” หรือ “ค้นไม่เจอ”

🎯 ภารกิจ

  1. เปลี่ยน RecipeList ของหน้า 49 ให้รับ state: RecipeListState ทาง props แทนการ import mock เอง · ใช้ union สามแบบเหมือนบท 84 (loading · error · success) แต่ให้ success ถือ Recipe[] ของบทนี้
  2. loading → วาด Skeleton ของ shadcn 8 ช่อง
  3. success แต่ลิสต์ว่าง → ข้อความ No recipes match your search กับลิงก์ Clear filters ตามสไลด์หน้า 43 โดยให้กล่องนี้กินเต็มความกว้างของ grid ไม่ใช่อัดอยู่ช่องแรก
  4. success ที่มีข้อมูล → วาดการ์ดด้วย key={recipe.id} เหมือนเดิม
  5. ที่ page.tsx: ใช้ grid แบบ responsive · และเปลี่ยนข้อความหัว 8 recipes · page 1 of 7 ให้คำนวณจาก state — ตอนโหลดต้องไม่ขึ้นตัวเลข
  6. ไม่ใส่ "use client" ใน RecipeList — แล้วพิสูจน์ว่า build · tsc · eslint ผ่านทั้งสามตัว และ curl นับได้ว่าแต่ละสถานะวาดของออกมาถูกจำนวน
// containers/RecipeList/RecipeList.tsx
import Link from "next/link";
import RecipeCard from "@/components/RecipeCard";
import { Skeleton } from "@/components/ui/skeleton";
import type { Recipe } from "./recipes";

export type RecipeListState =
  | { status: "loading" }
  | { status: "error"; message: string }
  | { status: "success"; recipes: Recipe[] };

const RecipeList = ({ state }: { state: RecipeListState }) => {
  if (state.status === "loading") {
    return (
      <>
        {Array.from({ length: 8 }).map((_, index) => (
          <Skeleton key={`skeleton-${index}`} className="aspect-[4/3] w-full" />
        ))}
      </>
    );
  }

  if (state.status === "error") {
    return (
      <div role="alert" className="col-span-full py-16 text-center">
        <p className="font-medium">Something went wrong</p>
        <p className="text-muted-foreground">{state.message}</p>
      </div>
    );
  }

  if (state.recipes.length === 0) {
    return (
      <div className="col-span-full py-16 text-center">
        <p className="font-medium">No recipes match your search</p>
        <Link href="/recipes" className="text-primary underline">
          Clear filters
        </Link>
      </div>
    );
  }

  return (
    <>
      {state.recipes.map((recipe) => (
        <RecipeCard
          key={recipe.id}
          name={recipe.name}
          level={recipe.level}
          imageUrl={recipe.imageUrl}
          owner={recipe.owner}
        />
      ))}
    </>
  );
};

export default RecipeList;
// containers/RecipeList/index.ts
export { default } from "./RecipeList";
export type { RecipeListState } from "./RecipeList";
// app/recipes/page.tsx
import { Container } from "@/components/Bases";
import RecipeList, { type RecipeListState } from "@/containers/RecipeList";
import { RECIPES_MOCK_DATA } from "@/containers/RecipeList/recipes";

const subtitle = (state: RecipeListState) => {
  if (state.status === "loading") return "Loading recipes…";
  if (state.status === "error") return "Could not load recipes";
  return `${state.recipes.length} recipes found`;
};

const RecipesPage = () => {
  const state: RecipeListState = { status: "success", recipes: RECIPES_MOCK_DATA };

  return (
    <div className="pt-10 pb-8">
      <Container>
        <p className="wongnok-text-h2">All Recipes</p>
        <p className="wongnok-text-body text-muted-foreground mt-2">{subtitle(state)}</p>
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-7">
          <RecipeList state={state} />
        </div>
      </Container>
    </div>
  );
};

export default RecipesPage;

เพื่อตรวจสองสถานะที่เหลือ ผมทำหน้าก็อปอีกสองหน้า ต่างจากข้างบนแค่บรรทัด state (และลบ import ที่ไม่ได้ใช้ ไม่งั้น eslint เตือน no-unused-vars):

$ diff app/recipes/page.tsx app/recipes-empty/page.tsx
3d2
< import { RECIPES_MOCK_DATA } from "@/containers/RecipeList/recipes";
12c11
<   const state: RecipeListState = { status: "success", recipes: RECIPES_MOCK_DATA };
---
>   const state: RecipeListState = { status: "success", recipes: [] };
$ diff app/recipes/page.tsx app/recipes-loading/page.tsx
3d2
< import { RECIPES_MOCK_DATA } from "@/containers/RecipeList/recipes";
12c11
<   const state: RecipeListState = { status: "success", recipes: RECIPES_MOCK_DATA };
---
>   const state: RecipeListState = { status: "loading" };

ผลตรวจจริง (RecipeCard ในโปรเจกต์ตรวจตอนนี้ติด "use client" แบบบท 84):

$ npx next build
Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /recipes
├ ○ /recipes-empty
├ ○ /recipes-loading
└ ○ /recipes-v1

○  (Static)  prerendered as static content

$ npx tsc --noEmit; echo "tsc rc=$?"
tsc rc=0
$ npx eslint components containers app; echo "eslint rc=$?"
eslint rc=0

แล้ว npx next start -p 385 · curl ทั้งสามหน้า นับ data-slot="card" (การ์ดของ shadcn) กับ data-slot="skeleton" ในส่วน <body> ดึงข้อความบรรทัดหัว และค้นข้อความของ empty state:

--- /recipes
card 8 skeleton 0
['8 recipes found']
[]
--- /recipes-empty
card 0 skeleton 0
['0 recipes found']
['No recipes match your search', 'Clear filters']
--- /recipes-loading
card 0 skeleton 8
['Loading recipes…']
[]

สามอย่างที่ควรสังเกตในเฉลย:

  • col-span-full ทำให้กล่อง empty / error กินทุกคอลัมน์ของ grid · ถ้าไม่ใส่ ข้อความจะอัดอยู่ในช่องแรกช่องเดียว เพราะมันเป็นลูกของ grid เหมือนการ์ด (RecipeList คืน Fragment <> ไม่ได้ห่อ grid ของตัวเอง — grid อยู่ที่ page.tsx ตามสไลด์หน้า 49)
  • skeleton ใช้ key จาก index ได้ — ขัดกับหัวข้อ key ไหม? ไม่ขัด · skeleton ทุกช่องเหมือนกัน ไม่มี state ข้างใน และไม่มีวันถูกเรียงใหม่ · กฎจริงคือ “อย่าใช้ index เมื่อรายการเปลี่ยนลำดับได้และลูกมี state” ไม่ใช่ “ห้ามใช้ index เด็ดขาด”
  • ข้อความหัวมาจาก state ตัวเดียวกับรายการ — ตัวเลขกับการ์ดจึงไม่มีทางขัดกันอีก (ต่างจาก 8 recipes เหนือการ์ด 12 ใบในหน้า 48) · ส่วน “page 1 of 7” ตัดทิ้งไปก่อน จนกว่าจะมี pagination จริง

สังเกตด้วยว่า RecipeList ตอนนี้ไม่รู้แล้วว่าข้อมูลมาจากไหน รู้แค่ว่าได้ state อะไรมา · คนที่รู้คือ page.tsx ซึ่งเป็นที่ที่เหมาะจะเรียก API จริงในอนาคต · และ Link ของ next/link ใช้ใน Server Component ได้โดยไม่ต้องมี "use client" (build ข้างบนผ่าน) — ต่างจากปุ่มที่ต้องมี onClick

Recap ทั้งกลุ่ม Next.js App — บท 79 ถึง 85

สไลด์ชุดนี้แบ่ง agenda ไว้ในหน้า 3 เป็นห้าช่วง (ช่วงที่ 5 “Recap” ไม่มีหน้าสไลด์จริง จบที่หน้า 50) · ตารางนี้จับคู่ช่วงของสไลด์กับบทเรียนในกลุ่มนี้

บท · สไลด์เรื่องสิ่งเดียวที่ต้องจำให้ได้
79 · หน้า 4–8 โครงโฟลเดอร์ — components กับ containers components/ วาดอย่างเดียว · containers/ ประกอบตามงานธุรกิจ · เป็นแค่ข้อตกลงของทีม Next.js ไม่รู้จัก
80 · หน้า 9–16 shadcn/ui shadcn ก็อปโค้ด component มาไว้ในโปรเจกต์เรา · “อย่าแก้ไฟล์ตรง ๆ ให้สร้างใหม่ตามแบบ shadcn” (หน้า 16)
81 · หน้า 17–27 ออกแบบด้วย AI · design tokens ตัดสินใจเรื่องสี / ตัวอักษร / ระยะ ครั้งเดียวเป็นตัวแปรใน globals.css แล้วทุก component อ้างชื่อตัวแปร ไม่ใช่รหัสสี
82 · หน้า 28–32 วิเคราะห์ Button · CVA · compound variants แยก “แบบของปุ่ม” เป็นแกน (variant · color · size) แล้วให้ cva ประกอบ class ให้แบบมี type
83 · หน้า 33–40 Web App Layout — Navbar · Container · Footer ของที่อยู่ทุกหน้าไปอยู่ใน app/layout.tsx ครั้งเดียว · Container คุมความกว้างให้ทุกส่วนตรงแนวกัน
84 · หน้า 41–46 Recipe List Page — 4 สถานะ · วิเคราะห์ RecipeCard ออกแบบหน้าจอทุกสถานะ (Default · Empty · Loading · Error) ก่อนเขียน ไม่ใช่แค่สถานะที่มีข้อมูล
85 · หน้า 47–50 หน้า All Recipes — mock แล้วแยก container key จาก id · "use client" ใส่เมื่อมีเหตุผลที่ build บังคับ · ของ hardcode จาก mock ต้องรู้ว่าอยู่ตรงไหน

ลองตอบดู

โค้ดสไลด์หน้า 48 ใช้ key={`recipe-${index}`} — รัน next dev แล้วเปิดหน้า จะเห็นอะไรใน terminal?

ตัวเลือกที่ 3 ถูก · ผมเปิด next dev แล้ว curl หน้า /recipes-v1 (โค้ดหน้า 48) สองรอบ log มีแค่บรรทัด GET /recipes-v1 200 ไม่มี warning · eslint ก็ exit 0 · warning ขึ้นเฉพาะหน้าที่ลบ key ทิ้ง
ตัวเลือกที่ 1 ผิด — React และ eslint ไม่มีทางรู้ว่า key มาจาก index มันเห็นแค่สตริง recipe-0, recipe-1 ที่ไม่ซ้ำกัน · ตัวเลือกที่ 2 คือข้อความตอนไม่ใส่ key ไม่ใช่ตอนใส่เป็น index · ตัวเลือกที่ 4 ผิด — key รับ string อะไรก็ได้ build ผ่านจริง
จุดที่ต้องจำ: key จาก index เป็นบั๊กที่ไม่มีเครื่องมือตัวไหนฟ้อง ต้องจับเองตอนอ่านโค้ด

RecipeCard ติด "use client" อยู่แล้วแบบบท 84 · ลบ "use client" ออกจาก RecipeList ของสไลด์หน้า 49 แล้ว build ใหม่ — ผลวัดจริงคืออะไร?

ตัวเลือกที่ 1 ถูก · ผลวัดจริงกรณี D → C: รวม HTML + JS หลัง gzip 189,287 → 189,044 ไบต์ ต่างกัน 243 ไบต์ (วัดกับ RecipeCard ตัวแทนในบทนี้)
ตัวเลือกที่ 2 ผิด — โฟลเดอร์ containers/ เป็นแค่ข้อตกลงของทีม Next.js ไม่รู้จักมันเลย · ตัวเลือกที่ 3 ผิด — ทั้ง 4 แบบที่วัดขึ้น ○ /recipes เหมือนกัน · ตัวเลือกที่ 4 ผิด — ไฟล์ JS ไม่มีชื่อเมนูแล้วก็จริง แต่ข้อมูลย้ายไปอยู่ใน HTML แทน (เจอ Mushroom Risotto ใน HTML 2 ครั้ง) เพราะการ์ดที่เป็น client ต้องได้ props ไปวาดใน browser
จุดที่ต้องจำ: "use client" ในไฟล์ที่ไม่มี hook / event ไม่จำเป็น · แต่ผลของการเอาออกขึ้นกับลูกของมัน — วัดทั้ง JS และ HTML ก่อนสรุปว่าคุ้ม

ตารางใช้ grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 — จอกว้าง 700px จะเห็นการ์ดแถวละกี่ใบ?

ตัวเลือกที่ 2 ถูก · CSS ที่ build ออกมาจริงคือ @media (min-width:40rem) สำหรับ sm และ @media (min-width:64rem) สำหรับ lg · 40rem = 640px · 64rem = 1024px
ตัวเลือกที่ 1 และ 3 ผิดเพราะลำดับการเขียน class ใน className ไม่มีผล — ตัวที่ชนะคือตัวที่ media query ตรงกับความกว้างจอ · ตัวเลือกที่ 4 ได้ตัวเลขถูกแต่เหตุผลผิดสองชั้น: sm ไม่ใช่ “เล็กกว่า” แต่เป็น “ตั้งแต่ขึ้นไป” (min-width) และขอบของมันคือ 640px ไม่ใช่ 768px
จุดที่ต้องจำ: Tailwind เป็น mobile-first · prefix แปลว่า “ตั้งแต่ขนาดนี้ขึ้นไป”

รายการใช้ key={`recipe-${index}`} · กดหัวใจเมนูแรก (Carbonara) แล้วลบเมนูแรกออก — หัวใจทึบไปอยู่ที่ไหน?

ตัวเลือกที่ 4 ถูก · ผลจากการกดจริงใน browser: ♥ Avocado Toast with Poached Egg
ตัวเลือกที่ 1 คือสิ่งที่ควรเกิด และเกิดจริงในชุดที่ใช้ key={recipe.id} · ตัวเลือกที่ 2 ผิดทิศ — React มองว่า key recipe-2 (ใบท้าย) หายไป state ของ recipe-0 จึงอยู่ที่ตำแหน่งแรกต่อ ไม่ได้ย้ายไปไหน · ตัวเลือกที่ 3 ผิด — ไม่มี error และไม่มี warning ใด ๆ ทั้ง console และ terminal
จุดที่ต้องจำ: state ของลูกผูกกับ key · key ผูกกับตำแหน่ง = state ผูกกับตำแหน่ง

ถามกลับได้เลย

ถ้าคุณวาง RecipeList ไว้ที่อื่น (เช่นใน app/recipes/ ข้างหน้า page ตามเคล็ดลับหน้า 8 ที่บอกว่า component ใน app/ ใช้แค่ในหน้านั้น) — ไม่ผิด เอามาคุยกันได้ว่าเลือกเพราะอะไร
ถ้าวัดขนาด JS ของโปรเจกต์คุณเองแล้วได้ตัวเลขต่างจากในบทนี้ นั่นปกติ เพราะ RecipeCard ของแต่ละคนไม่เหมือนกัน — เอาตัวเลขทั้งสองแบบมาเทียบให้ดู · และถ้าอยากทำสถานะ Error ต่อ ให้ถามก่อนว่า “error มาจากไหน” เพราะตอนนี้ข้อมูลยังเป็น mock ที่พังไม่เป็น

อ้างอิง